Commit Graph
3501 Commits
Author SHA1 Message Date
enricobuehler 790db5edbb Merge pull request 'The cache signing key is installed, and its DNS was never a dashboard click' (#318) from worktree-nix-binary-cache into main
Reviewed-on: unom/punktfunk#318
2026-08-18 22:04:09 +00:00
enricobuehler 8a4eac4c41 Merge pull request 'The Android console stops losing the pad, owns its Controllers page, and takes the whole phone panel' (#317) from worktree-phone-console-ui into main
Reviewed-on: unom/punktfunk#317
2026-08-18 21:50:54 +00:00
enricobuehler 7e4fe80793 feat(nix): install the cache signing key and correct how its ingress is provisioned
Two corrections and one thing actually done.

DNS here is not a dashboard click. unom/infra owns the unom.io zone in OpenTofu
(terraform/cloudflare/records.tf, applied by dns-cutover.yml), and that file's
`local.hostnames` set carries its own invariant: "a name here with no vhost 404s,
a vhost with no name here never cuts over." A record added by hand in Cloudflare
is out-of-band and risks the duplicate-record round-robin the file documents a few
lines further down — the same class of trap as hand-editing ~/caddy/Caddyfile on
the box. The setup steps said "in the unom.io Cloudflare zone" as though it were a
manual change; they now name both files, the workflow that applies them, and the
one-added-record check to expect from `plan`. unom/infra#20 makes the change.

The signing key is generated and `NIX_CACHE_SIGNING_KEY` is installed as a repo
Actions secret, so its public half is no longer a placeholder:

    punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=

pinned in both docs. The publish step still writes the same value to
/punktfunk-cache.pub, so the docs can always be checked against the cache itself —
and the wizard now compares the two and warns on a mismatch, because docs that
disagree with the cache mean users reject everything it serves.

The wizard drops to four stages. DNS and the vhost were separate stages when they
looked like separate manual steps; they are one PR against one repo, so they are
one stage. The key stage now detects the installed key, prints it, and refuses to
casually regenerate — a new key invalidates every signature already published and
breaks every user pinning the old one.

Verified: shellcheck + `bash -n` clean, 4 stages against TOTAL_STAGES=4, and the
already-installed path's key extraction tested against the real README.
2026-08-18 23:49:21 +02:00
enricobuehler c4cf53c1fc Merge pull request 'The Nix cache's setup steps pointed at a home-lab proxy that no longer exists' (#316) from worktree-nix-binary-cache into main
Reviewed-on: unom/punktfunk#316
2026-08-18 21:32:53 +00:00
enricobuehler d59a1a9606 feat(console-ui): the console takes the whole phone panel, and the library stops spending it
Four phone-sized fixes, one theme — a small screen was paying for chrome it
never asked about:

- The Android shell hides the system bars while it fronts the app (transient
  by swipe, restored on dispose), the same contract as the stream. This is
  also the safe-area fix: hidden bars report zero insets, so scrolled content
  no longer slices off at the visible gesture-bar line with bare backdrop
  below — only the display cutout remains a real inset.
- The design-unit scale's phone density floor rises 0.6 -> 0.75: on a 460 dpi
  panel the floor is what sets the scale (the couch term only wins on tablets
  and TVs), and 0.6 read a step too small in the hand. Still the documented
  on-glass knob.
- The library's sort/view bar appears only while it holds the pad (up from
  the field / the legend's 'Sort & view'), the Apple client's behaviour; the
  field takes the band's height back as it fades.
- The store/platform subtitle under the focused title is gone — the cover
  badge already says it — and the detail band shrinks 84 -> 64 units, most of
  a grid row on a phone.

Plus a guard the new scale makes necessary: the grid's two-column minimum
shrinks its covers to fit rather than clipping at the edges when a narrow
viewport times a high density leaves less width than two full cells.
2026-08-18 23:27:44 +02:00
enricobuehler 7c411f7ef4 fix(nix): the cache's setup steps described a topology that no longer exists
The bring-up instructions were copied from packaging/flatpak/README.md, which
still describes an edge proxy on `home-reverse-proxy-1` forwarding to
192.168.50.50. That home-lab topology is gone. packaging/winget/server/
compose.production.yml — the newest of the three and the only one written since
the move — says so outright: "the sibling docs/flatpak compose files still carry
stale comments … the public hostnames resolve straight to the hcloud box and are
served by Caddy there — no local proxy is involved." flatpak.unom.io resolves to
167.233.145.172, which is unom-1 itself, confirming it.

So the steps now match how docs and winget were actually stood up:

  * DNS in the unom.io Cloudflare zone, DNS-only, straight at the hcloud box.
  * The vhost in unom/infra `caddy/Caddyfile`, proxying to localhost:3250 —
    NOT 192.168.50.50, and NOT hand-edited on the box. ~/caddy/Caddyfile there
    looks like the config but is an rsynced copy with no .git to warn you; a
    vhost added only on the box lasts until the next deploy. That is how the
    winget source vanished on 2026-07-26, and it is now called out here too.
  * `caddy_target_ports` + terraform is dropped. It was the home-lab firewall
    allowlist; winget's setup, written post-move, has no such step.

Also adds the SNI diagnostic winget's README hard-won: Caddy 308s every Host on
:80 to https, including names it has never heard of, so probing port 80 proves
nothing — check the certificate by SNI instead.

scripts/setup-nix-cache.sh walks the five steps interactively (built from the
/wizard template): it opens each page, says exactly what to click, and verifies
each stage before moving on, because the failure signatures are easy to confuse
— a TLS handshake failure means the vhost is missing, a 502 means the container
is down, and a 404 means the cache is healthy and empty.

It also closes the loop the first version left open: it generates the signing
key locally (a local nix, or the nixos/nix image — MEASURED: both produce the
`name:base64` line, and convert-secret-to-public round-trips), then writes the
PUBLIC half straight into the two docs that carried a `<fill-in>` placeholder.
Nobody has to wait an hour for the first publish to print a value we can derive
up front. The secret half is shown once for pasting into Gitea and never
touches disk. Re-running detects an installed key and refuses to silently
replace it, since that would invalidate every signature already published.

Verified: shellcheck clean, `bash -n` clean, 5 stages against TOTAL_STAGES=5,
and the doc substitution tested against a real generated key — public keys are
base64 and contain `/`, so the sed uses `|` as its delimiter.
2026-08-18 23:26:06 +02:00
enricobuehler 9e47f746ba Merge pull request 'The DualSense's only playback route was a mono sink games overran, and both ends met the pad at -24 dB' (#315) from worktree-dualsense-handoff into main
Reviewed-on: unom/punktfunk#315
2026-08-18 21:22:08 +00:00
enricobuehler 13f8a1c5cd Merge remote-tracking branch 'origin/main' into worktree-dualsense-handoff 2026-08-18 23:18:07 +02:00
enricobuehler d5f2c63367 Merge pull request 'A moved mgmt port left every plugin and the tray dialing 47990 in silence; the Windows runner task now also writes a log file' (#314) from worktree-windows-runner-log-file into main
Reviewed-on: unom/punktfunk#314
2026-08-18 21:16:56 +00:00
enricobuehler 4e03dcc280 Merge pull request 'NixOS users compiled the whole workspace because we published no binaries' (#313) from worktree-nix-binary-cache into main
Reviewed-on: unom/punktfunk#313
2026-08-18 21:15:54 +00:00
enricobuehlerandClaude Opus 5 37813199b5 Merge origin/main — the WirePlumber DualSense policy and the UCM drop-in are complements
Three packaging conflicts, all the same shape: #307 added a
`60-punktfunk-dualsense.conf` install at the exact line this branch added the
ALSA UCM install to. Both sides kept — they act on different layers and neither
subsumes the other:

  * the WirePlumber rules govern how the pad's nodes BEHAVE once they exist
    (`node.always-process` so GE-Proton's raw open cannot race itself,
    `priority.driver = 0` so a pad never clocks somebody else's graph);
  * the UCM drop-in governs WHICH nodes exist at all (a `SpeakerHaptic` device
    at priority 200, so the 1-channel sink games overrun is never minted).

Checked rather than assumed: the drop-in's node-name matchers
(`~alsa_output.usb-Sony_Interactive_Entertainment_DualSense.*`) still match the
sink the UCM change introduces — `…DualSense_Wireless_Controller-00.HiFi__
SpeakerHaptic__sink` — so the policy follows the pad onto the new profile. And
neither touches volume, so the 0 dB pin on this branch is untouched by both.

The Android side of #301 deleted the Compose gamepad mirror, not
`SettingsScreen.kt`, so the "Controller speaker" subtitle survives; the Skia
console that replaced it carries no speaker row of its own (it opens Android's
connected-controllers view instead), so there is no second place to say it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:15:17 +02:00
enricobuehler 9cefa0a3ea feat(console-ui): connected controllers is the console's own screen — input stays on the pad
The Controllers row used to raise the D7 platform-screen mechanism: Android drew
the Compose ConsoleControllersScreen over the surface and suspended the console's
input until it closed. Now the page is a shared Skia screen
(screens/controllers.rs) pushed like any other settings sub-screen, so the console
keeps its own navigation, transitions and haptics on it — and a future desktop
build gets it for free (the row itself stays Android-only in row_on).

What genuinely cannot move into Rust stays with the host, asked for by ONE
parameterised command, ConsoleCmd::PadAction { action, pad_key }: the USB and
Bluetooth grant dialogs (sc2_bluetooth / sc2_usb / ds_usb), the rumble pulse on
the real InputDevice, and the DualSense pad-audio self test. SkiaConsoleShell
handles them with the same helpers the touch Controllers screen uses (testRumble,
the grant intents, nativePadAudioSelfTest), reporting through the notice toast, so
the support answer cannot drift between interfaces. PadInfo carries the three
fields the screen needed and the aggregated list already lacked (detail line,
forwarded, rumble), filled by ConsoleJson.pads from the same padInfoOf reader the
touch screen renders from.

PlatformScreen::Controllers is gone; the mechanism itself stays for Licenses,
which still suspends the console's input correctly (the probes gate on platformUp
as before). The Compose console variant and its screenshot scenes are deleted;
the touch ControllersScreen keeps the full page INCLUDING the live input test,
which deliberately did not move — the console only receives the aggregated
MenuSample, nowhere near a per-device axis/trigger readout (ponytail note at the
top of controllers.rs records the upgrade path).
2026-08-18 23:14:23 +02:00
enricobuehler 83f6164027 Merge pull request 'The takeover's own mask was the relogin storm that starved pad input' (#312) from worktree-sddm-relogin-storm-fix into main
Reviewed-on: unom/punktfunk#312
2026-08-18 21:14:11 +00:00
enricobuehler dd097d1ef2 chore(nix): record disk headroom after the publish build too
This job is now the heaviest on the fleet — a full workspace build plus
gamescope fills the store with tens of GB, and this fleet ran a runner out of
disk on 2026-08-06. The pre-existing Environment step reads df before any of
that happens, which is the less useful of the two moments.
2026-08-18 22:43:47 +02:00
enricobuehlerandClaude Opus 5 01946aa123 fix(pad-audio): a silent pad speaker looked like broken hardware, not a setting
A field session spent an evening measuring the HOST — sinks, tone injection,
per-channel peaks — for a pad speaker that was switched off at the other end.
A disabled toggle and dead hardware are indistinguishable from the pad, and
nothing said which one it was.

Two hints, at the two places somebody actually looks:

  * `punktfunk-session --pad-audio-test` now says, before the tone, whether
    `pad_speaker` / `pad_haptics` would stop a real session rendering the very
    thing the tone is about to prove works. The devtest drives the pad DIRECTLY
    and is otherwise blind to the settings, which is exactly what makes "the tone
    plays here but the game is silent" so confusing. It has to be said up front:
    the capability is never advertised when the toggle is off, so no later log
    line can catch it.
  * The Android settings row now states its default in the subtitle. Android is
    the one client that defaults this off, and that is a deliberate form-factor
    choice (a small, easily-startling loudspeaker in the user's hands, unlike
    haptics duplicating audio they already hear) — so the default stands and the
    row stops being silent about it. `pf_client_core`'s `default_pad_speaker` is
    `"pad"` and always has been, so the desktop clients were never the ones
    hiding it.

Worth recording for the next time: the host ALREADY had the signal. `native/
pad_audio.rs` logs `pad audio streaming … haptics=<bool> speaker=<bool>` at
stream start, and a client with the toggle off shows up there as
`speaker=false`. It was being printed on the host that was being measured all
evening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:42:18 +02:00
enricobuehler c0dcac7fa2 fix(sdk,tray): follow the mgmt port the host actually bound — a moved PUNKTFUNK_MGMT_BIND left every plugin and the tray dialing 47990
Field report 2026-08-18, confirmed: the operator had moved the management API off 47990
(`PUNKTFUNK_MGMT_BIND` in host.env — the supported way to share a box with Sunshine/Apollo). The
web console followed, because it reads `<config_dir>/mgmt-endpoint`, the one line the host
publishes on every start with the port it REALLY bound. Nothing else did:

- The plugin runner / SDK resolved `PUNKTFUNK_MGMT_URL` → literal `https://127.0.0.1:47990`.
  The runner is a scheduled task (Windows) / systemd unit that inherits nothing from host.env —
  on Windows it cannot even read it — so every plugin, and the runner's own log shipper, dialed a
  dead port forever. Task Running, plugins never registering, empty library, and "no logs at all".
- The tray defaulted `--mgmt-port` to 47990 and told the operator to edit the autostart command
  line if they moved the bind. Nobody knows to do that; the tray reports a running host as
  unreachable.

One source, two readers, no new file:
- `sdk/src/config.ts::publishedMgmtUrl` reads `mgmt-endpoint`; `resolveConfig` uses it after the
  env override and before the 47990 default. Every plugin `connect()` follows, on every platform,
  with no unit/task changes. `runner-cli.ts` additionally exports it into `PUNKTFUNK_MGMT_URL`
  before any plugin loads, so a plugin still carrying an older vendored `@punktfunk/host` follows
  too (on Windows `reconcileSharedSdk` cannot refresh the read-only tree, so old copies can
  outlive several host upgrades). An explicit PUNKTFUNK_MGMT_URL still wins.
- `pf_paths::published_mgmt_port` (std-only leaf; the tray now depends on it) parses the same
  line. The tray's `mgmt_port` becomes `Option<u16>`: `--mgmt-port` pins, `None` re-reads the file
  on every poll tick, so a host restarted on a new port is picked up without relaunching the tray.

Swept the rest: the web console (`windows::service::spawn_web`, the systemd unit, NixOS module)
already sourced the file; the host CLI, plugin-kit (goes through the SDK), gaming-mode console
and native clients derive the port from discovery / the Welcome — no other literal remained on a
loopback path. The console's web port (47992) is not operator-configurable, so the tray's
literal there is not the same bug.

Verified: SDK 83 tests pass (4 new: absent file → default, published line followed, env wins,
blank = unset), `tsc` clean, biome clean; `pf-paths` unit test; `cargo fmt --check` clean;
`cargo clippy -p pf-paths -D warnings` clean; `cargo check -p punktfunk-tray -p pf-paths` on
Linux (docker rust:1.96) — the tray is cfg-gated off macOS. Not built on Windows from here.
2026-08-18 22:42:13 +02:00
enricobuehlerandClaude Opus 5 654c09d067 fix(pad-audio): WirePlumber met every new pad card at -23.88 dB, and both ends stacked
WirePlumber starts every new card's sink at `device.routes.default-sink-volume`.
That is 0.4, and 0.4 is a CUBED number: what a mixer shows as 40 % is 0.4^3 =
0.064 of linear amplitude, -23.88 dB. The setting is global — it cannot be
scoped to one device from configuration — so there was no config file to ship,
and it fires again on every fresh card, which for a usbip pad is every attach.

Reasonable for a laptop speaker somebody is about to turn up. Wrong here twice:
nobody chose it and nobody would think to look for it (a pad's sink is not a
listening volume anyone reaches for, so it reads as weak hardware), and BOTH
ends of a session mint one. The game's samples cross this sink on the host and
the pad's own sink on the client, so the two multiply: 0.064^2 = -47.8 dB by the
time haptics reach a voice coil, which matches the -48 dB measured in the field.
That is the difference between "the haptics are subtle" and "I am not sure the
haptics are connected".

Both ends now set `channelVolumes` to unity — 1.0, which is unity in the linear
units the pod speaks AND in the cubed scale a mixer displays, the one value that
is unambiguous.

  * Client (`pin_sink_volume`, called from `correlate_pad_sink`): at every pick,
    so a card that re-minted its nodes after a profile change or a replug is
    re-pinned without anything having to notice. Skipped for the `split_parent`
    pick — that is a name lifted off another node's proplist, with no object of
    our own behind it, and pinning the sink that named it would pin the wrong
    node.
  * Host (`audio/linux/pad_card_volume.rs`, started when `PadUsbCapturer::open`
    succeeds): the host half matters because `pad_usb` captures at the pad's
    isochronous OUT endpoint, DOWNSTREAM of this sink — PipeWire applies the
    volume when it mixes into the ALSA device, so what we encode and send is
    already attenuated and no client-side fix can recover it. It retries for 15 s
    rather than firing once: the USB device is live well before its ALSA card is,
    and pinning before WirePlumber has applied the default we are undoing would
    simply be overwritten. Only sinks belonging to a DualSense CARD are touched —
    `device.id` is what keeps it off a host's own minted pad sink, which
    publishes the full DualSense identity on purpose.

Neither end restores on the way out, deliberately: the profile swap next door
overrides a choice the user made, this overrides a default nobody made, and
putting -24 dB back would be restoring the bug. `PUNKTFUNK_PAD_SINK_VOLUME=0`
disables both, for bisecting a box where something else attenuates.

Both pods are unit-tested for the shape that actually matters — one unity float
per channel. PipeWire ignores a `channelVolumes` whose length does not match the
port count, and an ignored pod looks exactly like the pin silently not working,
which is the -23.88 dB back again and just as invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:42:06 +02:00
enricobuehlerandClaude Opus 5 ab88a8fb40 fix(pad-audio): the DualSense's only playback route was a mono sink, and games overran it
A wired DualSense on Fedora 44 / Bazzite / Arch presents exactly one playback
sink: the 1-channel `…Default__Speaker__sink`. GE-Proton mints its synthetic
"Sony controller speaker" endpoint from that lone mono sink, and Marvel's
Spider-Man Remastered overruns it — reliably, ~74 s in:

    73.846 render_GetBuffer (…)->(5034, …)   <- GE's mono endpoint
    73.846 EXCEPTION_ACCESS_VIOLATION  info[0]=1 (WRITE)  info[1]=5CB9A000

Not a format mismatch: `GetMixFormat` and the game's `Initialize` both agree on
mono float32 `nBlockAlign 4`, and pulse sized `maxlength: 20136` = 5034 x 4
correctly. At the fault `rsi=rbp=0x13aa` (5034, the frame count) while
`rcx`/`rdx` are 5206/5207 — the copy loop had already run past the count. It is
a game/GE bug on a code path that ONLY EXISTS WHEN THE MONO SINK DOES.

So delete the mono sink rather than chase the overrun. `alsa-ucm-conf` describes
the pad as Speaker / Headphones / Mic / Headset and has never carried a
`SpeakerHaptic` device — the DualSense profile arrived upstream in 1.2.15
(36a111a) already without it, and the Deck's is a Valve downstream patch they
still carry on their own 1.2.16.1. With `SpeakerHaptic` at `PlaybackPriority
200` against `Speaker`'s 100 the card takes `HiFi (Mic, SpeakerHaptic)`, the
sink is the 4-channel one, and the mono sink — with the crash path — never
exists. The voice coils reach their own channels as a bonus.

Shipped WITHOUT replacing a file `alsa-ucm-conf` owns, which is what made this
awkward to package. `USB-Audio/USB-Audio.conf` ends with an unconditional,
optional include of `USB-Audio/conf.d/{vid}-{pid}.conf`, placed after its device
table has chosen `${var:ProfileName}` and before it includes the profile that
name resolves to — so a two-line drop-in keyed by 054c:0ce6 / 054c:0df2 swaps
the profile with no diversion, no `Conflicts`, and no `%config` fight. Verified
against alsa-lib rather than assumed: `ucm_cond.c` makes `Condition` optional
for a syntax-v8 `If` carrying `Append`, and `uc_mgr_evaluate_include` evaluates
each included subtree in place before moving to the next include, so the
`Define` lands before the profile include substitutes the variable. The hook and
the DualSense profile shipped in the SAME release (1.2.15), so every tree that
has the bug has the hook.

Host packages only (rpm — and therefore the Bazzite sysext, which unpacks the
RPMs — deb, Arch). The client already has a working fallback in
`ensure_pro_audio`, and a shared file in two co-installable packages is a file
conflict for a nicety. NixOS is not covered: it has no /usr/share/alsa/ucm2 to
drop into and needs a package override instead.

`scripts/ci/check-dualsense-ucm.sh` runs the whole chain on a real distro tree
with no hardware, via UCM's card-less `conf.virt.d` path with only the four card
built-ins stubbed. Against pristine Fedora 44 alsa-ucm-conf 1.2.16.1: baseline
`Headphones/Headset/Mic/Speaker`; with the drop-in, `SpeakerHaptic` and
`HeadphonesHaptic` too, `PlaybackPriority/SpeakerHaptic=200` over `Speaker`'s
100, `PlaybackPCM/SpeakerHaptic=…dualsense_haptic_out:…,1,1,2,3`. It exists
because this fix hooks another project's dispatcher: an upstream rename would
neuter it silently, and what comes back is the crash, not a quieter pad.
Negative-tested both ways (typo'd ProfileName, hook deleted).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:41:46 +02:00
enricobuehler 66249710b9 feat(nix): publish a binary cache so NixOS users stop compiling the workspace
Every other channel ships prebuilt binaries; Nix was the exception — `nix build`
meant the whole Rust workspace *and* a gamescope build from source, roughly an
hour, and `host.gamescopeHdr` defaults true so that compositor build is on the
critical path of every `services.punktfunk.host.enable = true`.

nix.yml grows a third tier: on a push to main it builds the Rust packages plus
gamescope, signs them, and publishes to https://nix.unom.io. No new trigger is
needed for releases — a release bumps the workspace version in Cargo.toml, which
is already in the path filter.

Gitea cannot host this: it has 23 package registry types and none is Nix, and the
protocol wants fixed anonymous paths at a URL root (/nix-cache-info,
/<hash>.narinfo, /nar/…) that /api/packages/{owner}/generic/… cannot express.
The RustFS at storage.unom.io would work mechanically — nix speaks
s3://…?endpoint= and the sccache credentials already exist — but it is a local
box on the home uplink with no CDN, so every user download would compete with CI,
and S3 answers 403 for a missing key unless the bucket policy grants anonymous
ListBucket. Nix treats anything other than 404 as a hard error rather than a
cache miss, so that would break users' builds for packages the cache never held.
So it goes on unom-1 beside the flatpak repo, as a caddy:2-alpine container
serving a static tree — which is all a binary cache is.

Three decisions worth keeping:

* Only punktfunk's own store paths are published. The rest of a runtime closure
  is stock nixpkgs, already on cache.nixos.org behind a real CDN; mirroring it
  would spend disk and home-to-cloud bandwidth to serve a worse copy. That is
  ~300 MB per publish instead of several GB. The step asserts every built output
  is matched by the name filter, so a future pname change fails the build rather
  than silently dropping the most expensive package from the cache.
* NARs upload before narinfos, and rsync runs without --delete. A narinfo whose
  NAR has not landed is a hard download failure for whoever fetches it in that
  window; a NAR nothing points at is merely invisible.
* prune.sh from the first publish, not after the box fills. The flatpak repo next
  door reached 3.84 GB publishing this same way with no sweep, on a box that has
  run out of disk before. It ages out narinfos, then sweeps unreferenced NARs —
  that order is the correctness argument, and it carries a self-check.

Verified locally: the Caddyfile serves a fixture cache with 200s on hits, 404 on
misses (the assertion the whole design rests on), and immutable cache headers;
prune.sh passes its self-test on Debian including empty and all-stale caches;
both workflows parse; the new run blocks pass shellcheck and dash -n. `set -eu`
rather than `-euo pipefail` — dash dies on the latter — with the two pipelines
whose left side must be able to fail rewritten as redirects.

Docs: README gains the substituter snippet, a maintainer runbook, and a warning
that inputs.punktfunk.inputs.nixpkgs.follows disables the cache entirely (every
store path changes, so every package rebuilds). The install guide gains the same
in short form.

The public key is a fill-in until the first publish prints it — see the setup
steps in packaging/nix/README.md.
2026-08-18 22:38:17 +02:00
enricobuehler 454531030d fix(android): a console that cannot draw yields to the touch UI instead of a gray screen
Connecting a controller could swap in the console shell over a SurfaceView
nothing would ever paint: the native create failing, the render thread dying,
or a GL context Android reclaimed all left the app on a gray screen for the
rest of the process — Kotlin only logged the Dead event.

SkiaConsole now exposes an observable [healthy] flag (false on create failure
or a Dead event) and App folds it into the gamepad-UI gate, so the touch UI
takes over. On the native side, a run of consecutive GL setup failures
(window surface / Skia wrap) — previously logged and retried forever, a hot
spin with a live surface — now ends the render thread through the same
release order as Quit, which raises Dead and hands the screen back.
2026-08-18 22:34:25 +02:00
enricobuehler a64a22ccfc fix(android): the console's pad probes survive the Controllers/Licenses pages
The MainActivity pad probes were one last-writer-wins slot. The Skia shell
installs its probes once (its effect keys never change); a Compose screen the
console opens over itself (Controllers, Licenses) overwrote that slot, and on
its way out nulled it — the shell never re-installed, so every gamepad press
after closing the page was silently dropped until the process died.

The slot is now a stack: each holder pushes its claim on install and removes
it BY IDENTITY on dispose, and dispatch consults the top. Whatever ordering
Compose produces — cross-fades composing both screens at once, non-LIFO
disposal — a leaving screen takes only its own entry, and the one underneath
resurfaces the moment it pops.
2026-08-18 22:34:10 +02:00
enricobuehlerandClaude Opus 5 3717466594 fix(gamescope): the takeover's own mask was the relogin storm that starved pad input
A managed takeover on the .41 SDDM-autologin box entered a ~4-5 logins/s
relogin storm and every udev consumer drowned in the fallout. It presented
as "my DualSense is not detected in the game, or only with an insane delay":
the pad enumerated perfectly and then delivered input at ~1.4 Hz instead of
250 Hz, because `winebus` re-enumerates udev on every event and therefore
stops reading `hidraw`. An evening went into disproving the pad stack, the
ALSA UCM, PipeWire and GE-Proton before the display manager was suspected.

The storm was ours. Measured on .41 (2026-08-18):

    /usr/share/wayland-sessions/gamescope-session-ogui-steam.desktop
      Exec=gamescope-session-plus ogui-steam
    /usr/share/gamescope-session-plus/gamescope-session-plus, last act:
      systemctl --user --wait start gamescope-session-plus@${CLIENT}.service

so the runtime mask this takeover lays sits *directly in sddm's relogin
path*. Every autologin then fails in milliseconds instead of taking the
seconds a real gamescope + Steam start costs, and sddm's `Relogin=true` has
no backoff: a slow, survivable relogin loop becomes a fork storm. 962 logind
sessions in 3.7 min, `Watching system buttons` re-scanned 5,688 times, a
box-wide udev `change` storm at ~20/s, iio-sensor-proxy crash-looping at ~16
starts/s as a udev-activated amplifier, load 26 on 12 cores.

The 2026-07-31 reading of the same box recorded the storm but concluded the
sddm helper "execs the session script directly, so the masked unit never
enters the picture". It does — one `systemctl` call further down. That is
why masking looked inert, and why it was left as the *degraded* takeover for
SDDM when the DM stop could not be achieved. Masking without the stop is not
a weaker defense. It is the storm's engine.

So the mask no longer substitutes for the DM stop, on any flavor:

* `dm_plan` loses its `mask` input, and with it `dm_survives_masked_unit` —
  the SDDM/plasmalogin split existed only to pick a degraded mode, and there
  is no longer one to pick. A planned DM stop that does not land now fails
  the takeover and the caller degrades to ATTACH, which is a fully working
  stream at the session's own mode. Fighting an autologin we cannot stop is
  strictly worse than not taking over: it costs the user their input plane.
* The mask is laid only after the stop has LANDED. Both failure arms bail,
  so reaching the mask proves no DM is up to relogin through it. That is
  also what keeps mask-fragile flavors safe — a stopped plasmalogin cannot
  trip its own start limit, and every restore path unmasks before restarting.
* `skip` is now `!any_live` on every flavor: killing loaded-but-inactive
  leftovers frees no Steam, and masking them under a running DM is the storm.

Which of the two candidates in the handoff this was is now settled, on the
box: not (b). `pkexec /usr/libexec/punktfunk/pf-dm-helper linger` run from a
sessionless `systemd --user` context — the host's own context — exits 0;
lingering is on, bazzite is in group `punktfunk` (965), helper and polkit
action are installed and correctly annotated. The privileged path works
there. It is (a), and the fix is the invariant above rather than a better
`any_live`, because the DM ending up alive next to our mask is the fault
whatever put it there.

`any_live` is tightened anyway, stated as the negative: systemd has exactly
two not-running ACTIVE states and the other four all mean the unit still
owns Steam. The old list missed `deactivating` (and `reloading`), so a unit
caught mid-teardown read as a dead leftover — a box that IS in gaming mode
sampled as idle, its Steam left holding the single instance our own launch
then collides with. Small window on an idle box, wide open on a churning
one, which is exactly when this is sampled.

Two things the handoff asked for that are about never spending that evening
again:

* `DmHelperError::shape()` — the four shapes need four different fixes
  (package it / install polkit / fix the action / join the group), so they
  ride along as a greppable `shape` field, and a planned-but-unachieved DM
  stop now logs at ERROR naming it instead of vanishing into a WARN.
* `watch_for_relogin_storm()` — logind names its session files after a
  monotonic id in `/run/systemd/sessions`, so the max is a free login
  counter. Two `read_dir`s five seconds apart on a detached thread; above
  1/s it says so at ERROR, and says what it means: no audio, input or
  PipeWire measurement taken during a storm is valid. Detect-and-report
  only — self-mitigation would tear down a live stream on a detector that
  has never been wrong in the field because it has never run there.

Finally, `systemctl_system` captures its stderr and logs it at DEBUG. On an
unprivileged host that verb is *expected* to fail — it is the cheap probe
before the pkexec helper — so systemctl's own "Access denied ... requires
interactive authentication" went to the journal on the normal, successful
path: two of them immediately before `INFO restored the display manager`.
That shape cost two sessions on its own, each spent explaining a failure
that had already succeeded one line later.

Checked in the CI image (linux/amd64): `cargo fmt --check`, `cargo clippy
--all-targets -D warnings`, and `cargo test -p pf-vdisplay --lib gamescope`
— 52 passed, 1 ignored (needs a live `systemd --user` manager).

Refs: punktfunk-planning design/sddm-relogin-storm-starves-input-handoff.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:29:01 +02:00
enricobuehler c7c9500e89 fix(windows/scripting): the runner task writes a log file, so a runner that can't reach the host is no longer silent
Field report 2026-08-18, Windows host on 0.30: PunktfunkScripting task Running, Playnite and
Steam plugins installed, library empty, and "no logs at all for plugins" — nowhere on the box.

That is by construction, not by accident. The runner's only log door is the log shipper, which
tees console output to `POST /plugins/logs` over the mgmt API; the scheduled task itself had no
console and no file. So every failure that stops the runner reaching the host — LocalService
lost its read grant on plugin-token / native-cert.pem, a moved mgmt bind, a TLS pin miss, a
401 — is exactly the failure the shipper cannot report, and it leaves the same picture:
task Running, plugins never registering, an empty grid, and nothing to send when asked for logs.

`scripting-run.cmd` now redirects the runner's stdout+stderr to
`%ProgramData%\punktfunk\plugin-state\runner.log`, keeping the previous run as `runner.log.1`.
plugin-state is the one directory `plugins enable` makes writable for LocalService, and it
inherits Users-read from the config dir, so the operator can `type` it from any prompt.
Writability is probed with `copy /y nul` first; if the dir is not writable (the task was started
by the installer before `plugins enable` ever ran) the runner starts unlogged as before rather
than not at all. No `goto`: the file is stored LF and cmd's label scan is unreliable there.

The console's empty-Plugins hint (en/de) and the plugin docs now name the file; the log-ship
header no longer claims the task writes no file. Verified by reading only — no Windows box
reachable from here; the cmd semantics used (`copy nul` as a write probe, `if defined` blocks,
leading redirect on `echo`) are the boring ones.
2026-08-18 22:26:08 +02:00
enricobuehler cfbde6aec7 Merge pull request 'Tell the agents where the issues live: AGENTS.md and docs/agents/' (#310) from worktree-agents-md-setup into main
Reviewed-on: unom/punktfunk#310
2026-08-18 19:33:35 +00:00
enricobuehler 0eb8f2d0f1 docs(agents): AGENTS.md — where the issues live, what the labels mean, which docs to read first
The mattpocock engineering skills (/triage, /to-tickets, /to-spec, /wayfinder,
/diagnosing-bugs, /code-review) all assume a per-repo configuration that this
repo never had: they need to know which tracker to talk to, which label strings
carry the five triage roles, and which docs to read before exploring. Without it
each of them guesses, and the guess is GitHub.

Which is wrong here in a way that fails quietly. This repo's remote is
git.unom.io — Gitea. `gh` is installed on the machine and will happily run
against github.com and find nothing; `glab` and `tea` are not installed at all.
So the tracker doc points at the connected `gitea` MCP server instead, and says
so in the first line, because the failure mode is an agent confidently reporting
an empty issue list.

Two traps in that MCP surface are worth the words they take:

  - `issue_write` applies labels by numeric ID and `remove_label` takes a
    `label_id`, while `list_issues` filters by label *name*. Applying a label
    therefore needs a `label_read` lookup first — passing the name silently
    gets you nowhere.

  - unom/punktfunk has no labels defined at all, on the repo or on the org
    (checked both). The first triage run has to create the five before it can
    apply any of them.

Gitea writes are outward-facing — a shared instance that mails on activity — so
the doc encodes the standing rule as a gate: reads are free, every write waits
for a go-ahead, subagents included.

Domain docs are single-context: one CONTEXT.md and one docs/adr/ at the root.
Neither exists yet and neither should be created pre-emptively; /domain-modeling
writes them when a term or a decision actually gets resolved. Twenty-seven
crates and nine client platforms is the shape of a repo that eventually wants a
CONTEXT-MAP.md, so the switch path is written down — but they serve one domain
today, and a glossary split four ways before it has a single entry is just four
empty files.

AGENTS.md rather than CLAUDE.md because neither existed, and the cross-tool
convention costs nothing here.
2026-08-18 21:31:48 +02:00
enricobuehler 59cc234055 Merge pull request 'Clicking a host connects to it again; the library goes back to the card menu' (#309) from worktree-revert-host-card-primary-connect into main
Reviewed-on: unom/punktfunk#309
2026-08-18 19:20:30 +00:00
enricobuehler 242292528c fix(clients/apple): clicking a host connects to it again; the library goes back to the menu
Reverts the primary-action swap from 22fdea66: a host card's tap ran
`onBrowseLibrary ?? onConnect`, so on Mac, iPad and Apple TV clicking a paired
machine dropped you on its game shelf and streaming the desktop had moved into
the context menu. That inverted the pattern every other surface still uses.

Back to: tap the card = connect, "Browse Library…" = a context-menu action
(both on the host's own card and on a pinned host+profile card, where its shelf
still launches with that card's profile).

No other client had drifted — the Rust console shell (Linux + the Android Skia
port) keeps A = connect and Y = library, the GTK card's activate emits
`CardOutput::Connect` with "Browse library…" in the overflow menu, Android's
Compose grid taps to connect with the same menu item, and the Apple console
shell's tiles activate into connect. Only the touch/desktop `HostCardView` had
the swap, which is why one fix covers all three Apple platforms.

Everything else the library round-trip landed stays: the wake-on-open retry, the
per-host cached catalog, the Resume badges, the scroll memory.

Verified on the Mac: swift build clean, swift test 375 tests / 0 failures.
2026-08-18 20:09:08 +02:00
enricobuehler 0f7d724154 Merge pull request 'The virtual DualSense wore a placeholder USB serial no real pad has' (#307) from worktree-usbip-dualsense-fix into main
Reviewed-on: unom/punktfunk#307
2026-08-18 17:50:00 +00:00
enricobuehler 475ff70a2a fix(pad): the virtual DualSense wore a placeholder USB serial no real pad has
`UsbDevice::default` in the vendored server fills iSerialNumber with the string
"Serial". A real DualSense reports none — this file's own doc comments assert that
twice — and ALSA bakes the value into the card id, so the pad presented as
`…DualSense_Wireless_Controller_Serial-00` where the hardware gives
`…DualSense_Wireless_Controller-00`. PipeWire then carries the difference into
every node name and into `device.serial`.

Clearing it makes every name a matcher can key on byte-identical to a physical
pad's.

Scope, honestly: this is fidelity, not a fix for anything currently broken. It was
written when the `_Serial` infix was a suspect for the dead haptics; it is not the
cause. GE-Proton's winepulse leg has since been observed matching
`alsa_output.usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller_Serial-00.Default__Speaker__sink`
with the placeholder present, so nothing depends on this today. It removes a
gratuitous difference from real hardware rather than fixing a symptom.

Also NOT a fix for UCM profile selection, though that was my first reading:
`alsa-ucm-conf` keys on `${CardComponents}` (`USB054c:0ce6`), so `DualSense-PS5.conf`
matched with the placeholder still present. Which profile a card lands on is decided
by verb priority, not by its name. The comment records that so it is not re-derived.

Gated with `cargo clippy -p pf-inject --all-targets -- -D warnings` in
`punktfunk-rust-ci:latest` (linux/amd64): exit 0.
2026-08-18 19:48:13 +02:00
enricobuehler d4db2e3331 Merge pull request 'The RPM %install tried to execute a %files path, so main builds no RPMs' (#306) from worktree-rpm-spec-stray-install into main
Reviewed-on: unom/punktfunk#306
2026-08-18 17:18:01 +00:00
enricobuehler c49b648032 fix(packaging/rpm): drop the stray %files path that %install tried to execute
The WirePlumber DualSense policy landed with its %files line duplicated into
%install, one line above the real install. rpm ran it as a command, and the
%install scriptlet died on 'No such file or directory' before it reached
anything else.
2026-08-18 19:13:59 +02:00
enricobuehler c94dafd4be Merge pull request 'The Linux capture sink drives its own PipeWire group — and says so when something else does' (#305) from worktree-audio-sink-clock-isolation into main
Reviewed-on: unom/punktfunk#305
2026-08-18 16:00:06 +00:00
enricobuehler abb084aac3 Merge pull request 'topology: exclusive was echoed back by the API and dropped on Hyprland and sway' (#304) from worktree-vdisplay-topology-wlroots-hyprland into main
Reviewed-on: unom/punktfunk#304
2026-08-18 15:54:11 +00:00
enricobuehler 3eede724d1 docs(host/audio): say what the driver-id line can and cannot promise
The comment claimed the daemon republishes node props "whenever the graph is
recalculated". It does not, quite: pw_impl_node_set_driver writes the key and marks the
props changed, but leaves the flush to the node's next info emission — in practice the
state change that accompanies the same recalculation. Near enough for a diagnostic, and
exactly the kind of near-enough that a later reader deserves to be told about rather than
discover. Also retires four "in stream-sink mode" phrasings that now describe both sink
modes.
2026-08-18 17:49:48 +02:00
enricobuehler 4b5a37dae2 feat(vdisplay): topology: exclusive was echoed back by the API and dropped on Hyprland and sway
Both wlr-family backends accepted the topology axis, the management API reported
it as the session's effective topology, and the backend logged a warning and did
nothing (sweep 13.18 shipped the warning, never the behaviour). Because
`resolve_topology` sends `auto` — the default — to `Exclusive` on any host without
a `PUNKTFUNK_COMPOSITOR` pin, and both compositors are auto-detected, the default
policy on every such box was an Exclusive that behaved as Extend. Closes #284.

`exclusive` now disables the operator's heads for the session and restores them
when the display GROUP's last member is torn down, via the same
`take_topology_restore` hand-off KWin uses — so the registry runs the restore
before the last output is reclaimed and the compositor never sees zero enabled
outputs, and a sibling session never has the desk re-enabled under it.

The disable filter is group-aware (design §6.1): enabled, not ours, and not
managed. On Hyprland `managed` is `PF-<pid>-<n>`, which covers a second host's
outputs too; on sway it is the `HEADLESS-` prefix, which also spares a headless
sway's own bootstrap output — the harmless failure, versus blacking out a live
sibling.

`primary` stays treated as extend, which is the honest answer rather than a gap:
Wayland has no primary-output concept and these compositors have only a focused
output, which the streamed head already holds since #283. It now says so
distinctly instead of sharing a warning with `exclusive`.

🛑 The Hyprland restore is `hyprctl reload`, and that is measured, not chosen.
Re-applying the head's own mode/position/scale — what design §5.2 and the issue
both assume — does NOT undo a disable: it answers `ok` and leaves `disabled:
true`. Probed 2026-08-18 against 0.56.2 (hyprlang) and 0.55.4 (Lua); every
targeted form was accepted and changed nothing, including `,enable` (answers
`invalid resolution`), `preferred,auto,1`, `monitorv2 disabled=false`, `keyword
unset monitor`, the Lua `disabled = false`, `dispatch dpms on` and
`forcerendererreload`. A runtime monitor rule is additive and the `disable` keeps
winning; only re-reading the config clears it. The headless output survives the
reload, so the issue's worry about losing it does not hold. Side effects are
documented at the call site and in the docs: other runtime `keyword` overrides are
dropped, and a hyprlang config re-runs its `exec =` lines. It runs only when a
session actually disabled something.

Disable is spelled per config era and confirmed by read-back, mirroring
`set_monitor_rule`: `keyword monitor <n>,disable` under hyprlang, `hl.monitor{
output = "<n>", disabled = true }` under Lua. Both eras reject the other's form at
exit 0, so the read-back — not the exit status, not the `ok` — is what decides.

Also fixes a marker gap that made one of those rejections read as success:
`hyprctl keyword` under the Lua config manager answers "keyword can't work with
non-legacy parsers. Use eval.", and `hyprctl_dispatch` matched "couldn't" but not
"can't". `set_monitor_rule` was covered by its own mode verification; nothing else
was.

⚠ The sway half is NOT exercised on a live sway — no box in the fleet runs one,
the same gap #283's `focus output` shipped with. The argv is sway's documented
surface, both shapes are pinned by tests (this file uses `output <name> <verb>`
AND `focus output <name>`, so getting one backwards is the live risk), and the
read-back turns a wrong guess into a warning naming the outputs rather than a
screen that silently stays dark.

Six new unit tests cover the group-aware filter on both backends, the headless
no-op case, both disable spellings and the marker set. 246 pass on Linux.
2026-08-18 17:49:45 +02:00
enricobuehler f24eb02692 fix(packaging): the DualSense driver-priority guard has to be 0, and cover the capture node
The shipped WirePlumber policy sets `priority.driver = 1` on a DS5's ALSA sink and says it
"keeps the pad from ever driving the graph". Read against PipeWire's own recalc, it does not:
`priority_driver` is unsigned and `pw_context_recalc_graph` skips a driver only when it is
`<= 0`. At 1 the pad is merely LAST in the ordering — and last is still elected whenever
nothing above it qualifies, which on a punktfunk host is the ordinary in-session state,
because claiming our own sink as the default output leaves the box's real card idle. With
`node.always-process` on the same node it is also permanently runnable, i.e. permanently
eligible.

Zero is the value that means excluded. The pad keeps driving the streams actually linked to
it — a driver always drives its own group, priority orders the election and nothing else —
so GE-Proton's haptics are unaffected.

The second rule covers the capture side of the same cards. That node is what clocked a
reporter's desktop audio for a whole session: in the Pro Audio profile it carries
`priority.driver = 2600`, never suspends, and had nothing linked to it at all — its only
function on that machine was to clock other people's graphs. The `alsa_output` matches never
touched it. Only the priority is set there; holding a device open is about the playback node
GE opens raw, and an always-processing microphone is not something this host should ask for.

Both of these are belt to the braces of the host-side fix — a capture group that carries its
own driver cannot be handed one — but they are worth having on their own: they are what stops
a pad from clocking anything else on the box, including a build that predates it.
2026-08-18 17:47:00 +02:00
enricobuehler e053292a80 docs: the host's own audio output, and what to do when another device clocks it
`PUNKTFUNK_STREAM_SINK` has existed since 0.29 and appeared in no documentation at all, which
was tolerable while it had two values and one of them was "don't". It has three now, the
default changed shape, and the change is visible to anyone who opens their audio settings
mid-session: there is a virtual output *and* a recording stream named `punktfunk-audio-…`,
and someone will want to know whether that is a leak. It is not.

Troubleshooting gains the section the 2026-08-14 investigation should have been able to
start from: audio broken up while video is steady, one WARN line naming the node that took
our clock, what that means for the sound (its stalls are now our holes), and the two ways
out — remove the loopback, or turn off the profile of a sound card that is reached over the
network and cannot be clocked at all.
2026-08-18 17:46:38 +02:00
enricobuehler ed075b98dd feat(host/audio): the capture line says which node is clocking it
On 2026-08-14 a reporter's audio stuttered. Answering "what is clocking desktop audio on
that box?" took four field logs, a purpose-built probe script, and finally a pw-top DRIVER
column — and the answer was a DualSense's sound card, attached over the network, that
nothing was linked to.

Every part of that answer was already in the daemon, one bind away. `node.driver-id` on our
own node names the driver of the group we are scheduled in; it is deliberately not in the
registry's announce set, so it needs the node bound and its `info` event read, and the
daemon republishes the props whenever the graph is recalculated.

So bind our node, keep a map of node id to name from the registry, and log the driver by
name whenever it changes. In null-sink mode there is exactly one right answer — our own sink
— so anything else is a WARN that names the culprit and says what it means: the holes in
this stream are that node's scheduling, not ours. The legacy topologies have no driver of
their own and borrow one by design, so there the same fact is an INFO that names it without
judging it.

On change rather than per window, deliberately: the driver moves a handful of times in a
session, and the 30 s capture summary is written from the RT callback while this arrives on
the main loop — a shared field would need cross-thread state for a value that a line of its
own carries better (`grep 'graph driver'`).
2026-08-18 17:43:28 +02:00
enricobuehler ec44079db4 fix(host/audio): the Linux capture sink drives its own graph group
Our desktop-audio sink was a pw_stream wearing media.class=Audio/Sink. A stream is
structurally a follower — it never drives — so the group it forms with the game's output
streams has no driver of its own, and PipeWire's recalc assigns every such group to the
highest-priority *running* driver anywhere on the box.

On a reporter's host that was the DualSense forwarded over VirtualHere: its USB-Audio card
sat in the Pro Audio profile (priority.driver=2600, never suspends), nothing was linked to
it, and it clocked our capture and the game's four streams for a whole 15-minute session.
Its frame counter is a kernel stub — vhci_get_frame_number() logs "not yet implemented" and
returns 0, ~1900 times a second — so snd-usb-audio could not recover a device clock for an
async endpoint arriving over the network. Not xruns: every cycle that happened was healthy
(ERR 11 in 15 min, WAIT never past 111 µs); the loss was in the interval *between* cycles.
3.9 delivery holes a second, worst 142 ms, and 15.4 % of the audio that user heard was
silence this host synthesized over the gaps.

So mint a real sink instead: a support.null-audio-sink adapter, created on our own
connection, captured through its monitor. That node IS a driver — the null sink publishes
node.driver=true and the audio adapter forwards its props — with a timerfd inside the
daemon's realtime data loop, so the group carries its own clock and no hardware (or
network-attached) device can be elected for it. It is the same object pactl load-module
module-null-sink creates, which is the most exercised virtual-sink path on Linux.

Three properties carry the design, and each is a decision rather than a default:

  * node.passive on the monitor tap. A link is passive when the input port is passive and
    the output node can suspend (any Audio/Sink), and a passive link makes neither end
    runnable by itself — so between sessions the group is idle and the null sink's timer
    parks with it. That answers, by construction, the "200 callbacks a second forever on a
    parked host" objection that kept node.always-process off the old stream sink. While a
    game plays, its own non-passive link makes the sink runnable and run_nodes() walks that
    through the monitor to us, so pause/resume accounting keeps today's meaning exactly.

  * node.force-quantum, not node.latency. A driver's quantum is the smallest node.latency
    among its followers, clamped — and then rounded DOWN to a power of two, because
    default.clock.power-of-two-quantum is true by default. That is why our 240-frame ask has
    silently been served as 128 on every stock Linux host since the capture was written: the
    callback runs at 2.67 ms, not the 5 ms it is designed around. force-quantum skips the
    rounding, and because this sink drives only its own group it forces nothing on anyone
    else's device — which is exactly why the same key would have been the wrong answer while
    we were borrowing somebody's hardware clock.

  * node.dont-fallback WITH node.linger, never one alone. WirePlumber 0.5 reads
    dont-fallback on its own as licence to destroy the stream the moment its target is not
    visible ("defined target not found"); linger turns that into "wait for it". Together
    they mean the tap can only ever record our sink — never, not even for the moment before
    ours registers, a hardware sink's monitor.

PUNKTFUNK_STREAM_SINK grows a third value: unset is the new topology, =stream is the 0.30
one (a one-release escape hatch, so a field A/B needs no build), =0 is still the legacy
default-sink-monitor follower. The routing claim, the capture callback, the stats line and
everything downstream are untouched — the sink node changed owner, not the pipeline.

The channel map now has one source and two views (pod and audio.position string), because a
created node is configured by properties while a stream is configured by a format pod, and a
map that disagreed with itself between them would swap channels silently.

Gated in punktfunk-rust-ci amd64: fmt, clippy --all-targets -D warnings (non-vacuous —
"Checking punktfunk-host" present), 607 tests. On-glass validation is still owed and is what
the plan gates the merge on: pw-top must show our sink at the top of its own group with the
game's streams and our tap under it, and 5 min of loud audio at delivered_pct=100 gaps=0 on
a box where a hardware sink is also running.
2026-08-18 17:40:35 +02:00
enricobuehler b81aee6821 Merge pull request 'Audio follow-ups: the jitter ring deepens by inserting instead of de-priming, client audio threads get real priority, fs-change syncs are rate-capped' (#303) from worktree-audio-followups into main
Reviewed-on: unom/punktfunk#303
2026-08-18 15:05:19 +00:00
enricobuehler 412991f6a3 Merge pull request 'DualSense haptics + speaker work in-game: four fixes between the game and the pad' (#302) from worktree-pad-audio-fidelity into main
Reviewed-on: unom/punktfunk#302
2026-08-18 15:02:16 +00:00
enricobuehler e43d67c721 Merge pull request 'The Android client adopts the Skia console — one gamepad UI on three platforms, the Compose mirror deleted' (#301) from worktree-android-skia-console into main
Reviewed-on: unom/punktfunk#301
2026-08-18 15:01:49 +00:00
enricobuehler ba227057b6 build(android): the Skia archives are served from our own Gitea release, by default
The WP6 hosting step lands where the project's git assets live: a public release on
git.unom.io (R2-backed) rather than the sccache bucket — unom/skia-binaries, tag 0.99.0,
mirroring rust-skia's own release layout so skia-bindings' {tag}/{key} url template fits
it verbatim:

  https://git.unom.io/unom/skia-binaries/releases/download/{tag}/skia-binaries-{key}.tar.gz

The release carries the armv7-linux-androideabi archive we build (rust-skia publishes
none) and byte-for-byte mirrors of the aarch64/x86_64 assets, each with its sha256 and
provenance in the release notes.

kit/build.gradle.kts now bakes that template in as the DEFAULT for every cargo-ndk run —
dev boxes and CI need no configuration, GitHub is out of the Android build path entirely,
and `-PskiaBinariesUrl` / the SKIA_BINARIES_URL env (and CI's repo variable) remain as
overrides for staging the next skia-safe bump's archives before they are released.

Proven: unauthenticated downloads of all three archives byte-match the local sha256s;
a clean-target cargo-ndk armv7 check installs from the release (`DOWNLOAD AND INSTALL
SUCCEEDED`); assembleDebug with the skia-bindings build dirs wiped re-fetched all three
ABIs from the default and built green.
2026-08-18 16:52:10 +02:00
enricobuehler 428fef2b20 refactor(android)!: the Compose console is deleted — the Skia shell is the console
WP7 of design/android-skia-console-port.md, unlocked by WP6 (every ABI carries the native
host) and the on-glass pass. The third gamepad-UI implementation is gone: Android's console
is now the same pf-console-ui shell Windows and Linux show, and the Compose mirror that
re-implemented it screen by screen is deleted rather than parked.

Deleted (~6.5 kLOC): GamepadHome, GamepadSettingsScreen, GamepadAddHostScreen,
GamepadDialogs, HomeTiles, the console halves of LibraryScreen (coverflow, ConsoleLibrary),
ConnectOverlay (ConnectTakeover), ConnectScreen / ConnectPrompts / AdaptiveDialogs
(gamepadUi branches, console options dialog), App.kt's GamepadShell + GamepadScreen — and
their tests (HomeTilesTest, GamepadSettingsLayout/RowsTest, ConsoleSubScreenRoutes/RowsTest,
the buildSettingsRows pins in GamepadPaletteTest, the Compose-console shot scenes; the tab
vocabulary is pinned by the Rust twin against the same shared vectors file).

Kept, deliberately: GamepadChrome/Nav/Aurora/Ink/Palette — the Compose platform screens the
console still opens (Controllers, Licences per D7), MainActivity's touch-UI pad navigation,
and the settings palette preview all draw with them; LibraryPosition + the TOUCH library
(the touch shell's own pushed screen); GamepadUi.kt (the mode switch).

App.kt folds the native host's presence into `gamepadUi` itself: no host, no console —
a controller then drives the touch UI through Compose focus (nothing left to fall back to).
The sysprop becomes a triage switch (`debug.punktfunk.console_backend=none` forces the
touch UI on glass; the `compose` value is meaningless now).

Marked breaking for the store-screenshot surface: the Compose console's marketing scenes
(console home, console settings, coverflow, connect takeover) are gone — the Skia shell
renders over native GL and cannot compose under Roborazzi. Its shots come from the desktop
screenshot dump (the same pixels by construction) or a device capture.

Gates: :app:testDebugUnitTest + :kit:testDebugUnitTest green, installDebug on the NP3 and
the console renders on glass (carousel, OS marks, legend, aurora — the desktop look).
2026-08-18 16:43:06 +02:00
enricobuehler 0a6a49a9aa feat(packaging): a WirePlumber policy holds a DualSense's sound card for GE-Proton
GE-Proton's DS5 haptic router opens the pad sink's backing hw: device RAW whenever it is
free — then its own path re-probe EBUSYs against its own handle, invalidates the stream,
and spins a 100 Hz "device generation" refresh loop: haptics dead, speaker dead, and in
one game a buffer race in the same machinery crashed the title outright. On SteamOS,
where that code was developed, PipeWire always holds the device, so GE lands on its
well-tested Pulse-routing fallback immediately and none of this fires.

Ship the SteamOS-shaped environment: node.always-process + no suspend keeps PipeWire
holding the device from the moment the card appears, and priority.driver = 1 keeps the
pad — whose USB audio clock (virtual or physical) is nobody's idea of a house clock —
from ever driving the graph. Installed by rpm/deb/arch/nix into
/usr/share/wireplumber/wireplumber.conf.d/. Matches both DS5 product-string spellings;
covers physically plugged pads on a headless host identically.
2026-08-18 16:42:33 +02:00
enricobuehler e356e354f2 fix(pad-audio): the speaker lane gets the music coder, the haptics lane keeps LowDelay
Both 0xD1 lanes encoded with Application::LowDelay at 64 kbps CBR — right for voice-coil
rumble (felt latency, band-limited content), audibly wrong for the pad speaker, which
carries real programme audio: on glass it "sounded insanely compressed". The speaker lane
now uses the full Application::Audio coder at 96 kbps (~120 bytes per 10 ms frame, still
far under one MTU); its few ms of extra algorithmic delay are inaudible on a speaker.
Haptics unchanged.
2026-08-18 16:42:31 +02:00
enricobuehler f737414949 fix(usbip): pace ISO completions against an absolute deadline, not relative sleeps
The simulator slept `service_interval × packets` per URB, measured from "now" at handling
time — so tokio timer slop, socket I/O and handler lock waits all added ON TOP of the
nominal period, every URB. The virtual pad's audio clock ran measurably slow under load
(~26 %: hw_ptr advanced ~35.7k frames/s against a 48 kHz stream), the PCM backed up into
xruns, and — because snd-usb-audio's clock IS URB completion — anything clocked off the
device dragged with it. On the test box the pad sink became the PipeWire graph driver and
pulled the desktop capture down to 50 % delivery ("insanely distorted" stream audio).

Pace against a per-endpoint absolute deadline ledger instead: each URB advances the
endpoint's deadline by exactly its nominal duration and sleeps until that instant, so
overhead eats into the next sleep rather than accumulating. A stall beyond 20 ms
re-anchors to now instead of fast-forwarding a burst. Measured after: 48005 frames/s.
Two paused-clock tests pin the exact rate and the re-anchor.
2026-08-18 16:42:29 +02:00
enricobuehler dff2769ba9 fix(pad): the usbip capture forwarded the pad's hardware quad as the wire's speaker pair
The isochronous endpoint carries the DualSense's own channel map — ch0 = headphone LEFT,
ch1 = headphone RIGHT and the built-in mono speaker, ch2/3 = the voice coils — because
everything a game writes has already been folded to hardware channels by the host's UCM
split (or written there directly) before it reaches the endpoint. The 0xD1 wire contract
instead puts the *speaker pair* on ch0/1. Forwarding the quad verbatim shipped headphone-
left (silence) as wire speaker-left and the actual speaker as wire speaker-right, which
the client renders onto the one split-sink channel that current PipeWire never wires to
the physical speaker. Field-diagnosed on glass end-to-end: haptics felt, speaker dead,
a test tone measured on exactly one channel at each hop.

Duplicate the hardware speaker channel across the wire's speaker pair; pass the coils
through; drop headphone-left deliberately (a remote pad's jack is not a wire surface).
The stream-sink capture path (uhid pads) already emits the logical layout and is
unchanged.
2026-08-18 16:42:27 +02:00
enricobuehler f3a69d5b56 build(android): the 32-bit ABI gets the Skia console too, from a self-hosted archive
WP6 of design/android-skia-console-port.md. rust-skia publishes prebuilt Skia archives for
aarch64/x86_64/i686-linux-android but not armv7-linux-androideabi, and the 32-bit TV boxes
(Chromecast-with-Google-TV class) are console-UI-always devices — so the archive is built once
with skia-bindings itself (FORCE_SKIA_BUILD=1 for pf-console-ui's gl+textlayout feature set:
1666 ninja steps, ~80 s on an M-series Mac) and packed in the exact skia-binaries/ layout the
downloader unpacks. Key a25a0fdb7d90429aa2d1-armv7-linux-androideabi-gl-jpegd-jpege-pdf-textlayout,
sha256 4867856b… (table in kit/build.gradle.kts beside the two GitHub keys).

- clients/android/native: the console host and its deps are on every Android ABI now
  (the arch gate is gone; nativeConsoleAvailable() answers true on all three).
- kit/build.gradle.kts: forwards SKIA_BINARIES_URL (-PskiaBinariesUrl or the env) to every
  cargo-ndk run — the {tag}/{key} template that serves all three keys from one mirror.
- .gitea/workflows/android.yml: the repo variable SKIA_BINARIES_URL feeds it.

Proven: on a clean target dir with the url template pointing at a local mirror, every ABI's
skia-bindings log says DOWNLOAD AND INSTALL SUCCEEDED (armv7 included), the full debug APK
builds and installs, and cargo ndk clippy -D warnings is green on all three ABIs.

⚠ Until the three archives are hosted (storage.unom, then set the CI variable), an armv7 build
without the url falls back to a Skia source build — minutes with ninja present, a red leg
without it. Hosting is the one step left in this WP.
2026-08-18 16:28:02 +02:00
enricobuehler 6d1c7187d3 feat(android): the Skia console fronts the gamepad UI, behind a transition switch
WP4 + WP5 of design/android-skia-console-port.md. The Android client now hosts the same
console shell the Linux/Windows session binary shows — drawn by native over EGL/GLES on a
SurfaceView (the WP3 host) — and Kotlin keeps every service behind it.

Kotlin (clients/android/app/.../console/):
- SkiaConsole — the process-lifetime holder (the console's stack survives a stream, exactly
  as the desktop's does): host rows from KnownHostStore + discovery + the reachability sweep,
  the library pipeline (cache first, wake + retry across the boot window, catalog, running
  set, posters over mTLS as bytes), pairing via nativePair, the wake-and-wait loop, the
  settings round-trip (Settings ↔ trust::Settings JSON; console-owned keys persisted whole),
  OverlayAction::Launch → connectToHost with the host's profile / a pinned card's one-off /
  the request-access budget, CancelConnect, Quit → task to back, CopyText → clipboard, and
  punktfunk:// links (known-and-pinned dials; anything needing trust is a notice — a link
  never establishes trust).
- SkiaConsoleShell — the composable: the SurfaceView + its lifecycle, safe-area insets
  (systemBars ∪ displayCutout, in surface px), a density floor under the couch scale for
  phones, MainActivity's pad probes feeding raw MenuSamples (buttons, stick, HAT) into the
  shared MenuNav, remote D-pad keys as discrete events, hardware keys as Keys + typed text,
  the system Back as B, touch as pointer input, haptic pulses, and the two Compose overlays
  the console can open (Controllers, Licences).
- ConsoleJson — the wire, in the model types' own serde shapes.
- App.kt picks SkiaConsoleShell over GamepadShell when SkiaConsole.wanted(): the native host
  exists (64-bit ABIs) and sysprop debug.punktfunk.console_backend != compose. The Compose
  console stays until the on-glass matrix and the armv7 archive land (WP7).
- NativeBridge: the nativeConsole* externs. GameEntry.platform parsed + cached (Collections).

pf-console-ui (WP5): the Android-only settings rows over Settings::extra `android.*` keys
(low-latency decode, phone rumble/gyro, SC2 passthrough, DualSense capture, controller-UI
mode) and two platform-screen action rows (Controllers, Licences) that raise the new
ConsoleCmd::OpenPlatformScreen; row_on() keeps them off the desktop's list, which shows
exactly the rows it did (pinned by test). "Send logs" hides where nothing serves it.

pf-console-ui (test harness): a test-only fixed-step clock for the screenshot dump. The
dump's aurora phase was wall-clock time, so two runs agreed only at equal machine load and
speed — a pixel comparison of main against a candidate read as a regression that was pure
drift. With the fixed step the same tree renders the same pixels anywhere.

Gates: container fmt + clippy -D warnings + build + tests; cargo ndk clippy -D warnings on
arm64-v8a / x86_64 / armeabi-v7a; :app:compileDebugKotlin; the fixed-clock dump of this
tree vs main.
2026-08-18 15:55:01 +02:00