Compare commits

...
Author SHA1 Message Date
enricobuehler ea18b84138 docs(omarchy): the client side of the page
Install, the automatic theme (and its two switches), and the
Super+Space host rows with their opt-in and their exact-removal.
2026-08-31 14:13:11 +02:00
enricobuehler 2586a239b2 feat(client/linux): your saved hosts in the Omarchy menu
One managed block in omarchy-menu.jsonc: the punktfunk submenu (merging
with the host tool's block by reused ids), Open Punktfunk, the couch
console, and a connect row per saved host -- typing a host's name into
Super+Space and pressing enter starts the stream. Wake rows where a
MAC is known.

The menu cannot generate rows at runtime -- its provider field resolves
only against a map baked into the shell (verified in Menu.qml, Omarchy
4.0.1) -- so the rows are static and kept true instead:
KnownHosts::save calls the sync, and save is the one door every store
mutation walks through, in the GTK app, the couch console and the CLI
alike. omarchy-menu refresh repaints an open shell.

Opt-in (a preferences switch, or --omarchy-menu on); off removes
exactly our block -- on the testbox the file came back byte-identical.
2026-08-31 14:13:08 +02:00
enricobuehler e4c1f619fe feat(client): follow the Omarchy theme, live, on every client surface
The GTK shell already recoloured; this moves the reader into
pf-client-core and spends it twice more, so the console wears the theme
too and a client-only box needs no setup at all:

- The reader falls back to the theme's own colors.toml when our
  rendered template is absent. Only host boxes render the template, and
  a box that is only a screen was themed nowhere.
- The console gets a follow-system palette: a quiet field mixed from
  the theme's own ground, ink and accent derived with the same 3:1 lift
  the shell uses. The session binary polls the file every 2 s and
  publishes on change; the shell's existing palette-rebuild seam does
  the rest, so an omarchy-theme-set lands mid-session with no restart.
- One switch rules both surfaces: follow_os_theme, default on, its row
  shown only where a theme is actually published -- and the console
  hides its Background picker while the system theme rules it.
2026-08-31 14:13:06 +02:00
enricobuehler 012ce3e078 fix(omarchy): the menu validator ate URLs, refusing real files
menu_is_valid stripped `//` to end-of-line with no idea of strings, so
the console row's own action -- it carries a https://localhost:47992
fallback -- was cut mid-string and the whole file refused as
unparsable. Found on the testbox: the script could no longer edit the
very file it had written.

The replacement scans character-wise, stripping comments and forgiving
trailing commas only OUTSIDE strings. The client mirrors the algorithm
in Rust (omarchy_menu::jsonc_parse); change one, change both.
2026-08-31 14:13:03 +02:00
enricobuehler 26b86f6cee docs(client/linux): name the invocation a display test really needs
`--ignored` alone starts all three display tests in one process, and GTK
initialises once per process from one thread -- libtest gives each test
its own thread, `--test-threads=1` included. So whichever starts first
wins and the rest panic with "Attempted to initialize GTK from two
different threads". Each passes alone; the note said otherwise.
2026-08-31 13:23:12 +02:00
enricobuehler 90c60d0212 feat(client/linux): follow the Omarchy theme
Every colour in the shell's own stylesheet already resolved through
libadwaita's named palette, so wearing the desktop's theme is a matter
of redefining those names -- not of touching a widget. Omarchy has been
rendering the four values on every theme switch since the host
integration landed, and until now only the web console read them.

Hex only. The surfaces are mixed out of the background/foreground pair,
so the numbers are needed anyway; GTK's CSS parser knows nothing of
oklch; and an unrendered template still holds its literal `{{ accent }}`,
which the same check is what refuses.

The accent is split, because libadwaita spends one name on accented text
and another on a fill. The fill keeps the theme's exact colour, and only
the text form is lifted toward the foreground until it reads at 3:1 --
several Omarchy light themes pick an accent that is handsome as a fill
and illegible as a label.

success, warning and destructive keep libadwaita's values: a theme with
a red accent must not make "Unpair" and "Connect" the same colour.

Colours only. The widget vocabulary stays Adwaita.
2026-08-31 13:23:12 +02:00
enricobuehler b7c29329e5 Merge pull request 'chore(release): cut 0.34.0' (#467) from release/v0.34.0 into main 2026-08-31 00:25:03 +00:00
enricobuehler 7f69100d39 fix(client/windows): allow the host tile's eighth argument
The Windows clippy job has failed on main since a04c8cee: giving the
avatar its OS mark added `os` to `host_tile`, taking it to eight
arguments against clippy's threshold of seven, and `-D warnings` makes
that an error. The job never runs on a Mac or a Linux runner, so the
break reached main green.

Take the allow, as the two neighbours at this limit already do. All
three call sites pass a different mix of the optional tail, so a props
struct would only move the argument list into each of them.

`edit_profile_modal` and `described_overridable` sit at exactly seven
and are untouched.
2026-08-31 01:59:38 +02:00
enricobuehler 2531207f04 chore(release): cut 0.34.0
140 commits since v0.33.0. The release is a control surface for everyone
who streams to a screen they hold: a six-button ring opened by a
two-finger twist, editable by using it rather than by reading a list,
plus an on-screen controller on phones and tablets.

The Steam Controller 2 passthrough leaves Linux. It now captures from
Apple and Android devices and reaches a Windows host, still presented as
itself rather than flattened into a generic pad.

This is not a drop-in for embedders. The C ABI moves 26 to 28, and v27
widens PunktfunkHidOutput from 19 to 85 bytes -- a recompile, not a
relink, with the version equality check as the guard. Every other
version number stands still: wire 2, driver 6, gamepad channel 3.
Windows packagers additionally need the pf_gamepad package rebuilt, for
its eighth hardware id.

api/openapi.json is regenerated rather than re-stamped this time, and
the surface is verified rather than assumed: punktfunk-host compiles on
macOS again, so the staleness test runs here. Every prior release
carried a caveat for exactly that reason.

Also prunes PUNKTFUNK_STANDBY_SINK_KEEP from the undocumented-env
baseline, which the drift gate has warned about since #452 documented
it while still exiting 0.

Gates on the Mac: cargo fmt --all --check, cargo test -p punktfunk-core
--lib --features quic (511 passed, 0 failed), the C ABI harness
(abi_version=28), the openapi staleness test, check-docs-drift.sh,
check-docs-links.sh, cargo audit (2 allowed warnings, no yanked
crates), both openapi copies byte-identical, and Play notes at 493/500
chars and unique.
2026-08-31 01:46:08 +02:00
enricobuehler a774690a78 Merge pull request 'fix(gamescope): offer the refresh-rate set on the SteamOS path' (#466) from fix/gamescope-steamos-refresh-rates into main
Reviewed-on: unom/punktfunk#466
2026-08-30 23:39:13 +00:00
enricobuehler 41a070f6cc Merge pull request 'docs: correct stale AMD, multi-client and Omarchy claims' (#465) from docs/stale-support-claims into main
Reviewed-on: unom/punktfunk#465
2026-08-30 23:38:55 +00:00
enricobuehler 3b8a8230cd Merge pull request 'The flatpak's gamescope env stops faking Gaming Mode' (#464) from worktree-gamescope-env-fullscreen into main
Reviewed-on: unom/punktfunk#464
2026-08-30 23:38:15 +00:00
enricobuehler 14d69c3011 Merge pull request 'feat(clients): one Lucide icon set, and the quick-action editors to match' (#463) from feat/quick-actions-lucide-icons into main
Reviewed-on: unom/punktfunk#463
2026-08-30 23:37:29 +00:00
enricobuehler b5a1996f5b Merge pull request 'test(pf-vkdecode): diff a field capture against ffmpeg, frame by frame' (#461) from worktree-vkdecode-field-parity into main
Reviewed-on: unom/punktfunk#461
2026-08-30 23:36:45 +00:00
enricobuehler a7d9d29f4e Merge pull request 'feat(web): announce a pairing knock wherever you are' (#459) from feat/console-pairing-toast into main
Reviewed-on: unom/punktfunk#459
2026-08-30 23:36:03 +00:00
enricobuehler cd48ee991e Merge pull request 'fix(client/apple): let the pad drive two overlays on tvOS' (#458) from fix/tvos-overlay-pad-input into main
Reviewed-on: unom/punktfunk#458
2026-08-30 23:35:10 +00:00
enricobuehler 9f0a757999 Merge pull request 'fix(host): compile punktfunk-host on macOS' (#456) from fix/macos-host-compile-holes into main
Reviewed-on: unom/punktfunk#456
2026-08-30 23:34:42 +00:00
enricobuehler 5ae29d72e8 fix(client/windows): finish the avatar's OS-mark signature change
`windows-client.yml` fails to build this branch on both arm64 and x64,
four errors, all from the commit that gave the host card an OS mark:

  hosts.rs:144,145  doc comment on a function parameter
  pair.rs:97        avatar(&target.name)  — argument #2 missing
  stream.rs:37      avatar(&host)         — argument #2 missing

`avatar` grew a second parameter and two of its three call sites were
not updated. Neither has an OS chain to pass: `Target` carries name,
address, port, fingerprint, MAC and profile, and no OS field. So both
pass `""`, which is what `avatar` documents as "keeps the monogram" —
and what `stream.rs`'s own header comment already said it wanted.

The parameter comment becomes `//`. Its text was worth keeping; rustc
simply will not take `///` there.

⚠ NOT compiled locally: this crate cannot be built for Windows from a
Mac, and scripts/xcheck.sh does not carry punktfunk-client-windows. CI
is the verifier.
2026-08-31 01:29:26 +02:00
enricobuehler 7db513683d Merge main, keeping this branch's App::status() accessor
Both this branch and #460 fixed the same abort, independently and within
the hour. #460 merged first, patching four call sites inline; this one
routes all five through one `App::status()`, which is the better shape
and also covers the writer in `run()` that the inline pass left alone.

Conflict resolved to this branch for `win.rs`. Afterwards the file holds
exactly one `lock()` — inside the accessor — so every reader goes
through it. `check-unsafe-hygiene.sh` reports all three gates clean and
rustfmt is clean under the pinned 2021 style edition.
2026-08-31 01:28:18 +02:00
enricobuehler 0f7bc56588 docs(gamescope): say to restart the host after installing the build
A field reporter installed punktfunk-gamescope under a running host
and kept streaming SDR, with no error to explain it. The host probes
the gamescope binary once per process and caches the answer, because
a session's bit depth must be settled before the display exists - so
a build installed mid-run is never seen.

The install section listed a command for every distro and never
mentioned the restart. Add it, and name the two startup log lines
that tell the two states apart.
2026-08-31 01:27:07 +02:00
enricobuehler 45048dbee8 fix(gamescope): offer the refresh-rate set on the SteamOS path
A SteamOS-style session (Valve's /usr/lib/steamos/gamescope-session, as
CachyOS ships it) never got --custom-refresh-rates, so Steam's
in-session display menu showed one refresh entry and no resolutions,
and games paced themselves to that single number.

The two gamescope-session-plus paths hand the set to the SCRIPT as
CUSTOM_REFRESH_RATES and let it build the flag. Valve's script has
never read that variable, and the PATH shim forwards only
PF_HDR_ARGS - so the flag reached no SteamOS session at all.

Chain refresh_rate_args into that drop-in's PF_HDR_ARGS, keyed on the
session's own mode like launch_session's `offered`. Split the list
formatting into a pure refresh_rate_list so the no-whitespace
invariant that the unquoted ${PF_HDR_ARGS} depends on has a test.
2026-08-31 01:26:50 +02:00
enricobuehler 3ac6ad5699 docs: correct stale AMD, multi-client and Omarchy claims
Four statements had outlived the work they described, and each one
tells a reader that something shipped is missing.

The README, the roadmap and the Windows host page all said the AMD
(AMF) and Intel (QSV) encoders were CI-green only. The support matrix
— which the roadmap itself names as the arbiter — has recorded since
0.31 that AMF was validated on a Ryzen 7000 iGPU and QSV on Arc. A
reader comparing the two pages could only conclude AMD is unsupported
in the shipping build.

The roadmap listed concurrent clients under "Next", with the fact that
they already work buried in a closing parenthesis. Per-client virtual
displays have shipped for many releases; what is actually missing is
identity and per-session isolation, so the entry now leads with that.

The matrix still said no one had run a Hyprland virtual output or the
Omarchy integration on real hardware. 0.33.0 did both, end to end, on
a real Omarchy box. Only the post-theme-switch re-apply stayed
untested, so that is the one entry left standing.

Also replaces the roadmap's "shipped in 0.19 through 0.22.3" list,
eleven releases out of date, with current work and a link that cannot
go stale.
2026-08-31 01:20:00 +02:00
enricobuehler 7580bd70fd fix(clients): the flatpak's gamescope env stops faking Gaming Mode
A field report on 2026-08-30: "the GTK client is just launching in
fullscreen". Reproduced on a plain GNOME desktop (.21), launched from
the .desktop entry, with no Deck and no gamescope anywhere on the box.

The shell fullscreens itself when `cli::fullscreen_mode()` is true, and
that read `GAMESCOPE_WAYLAND_DISPLAY` as proof of Gaming Mode. Our own
flatpak sets it unconditionally: `packaging/flatpak/io.unom.Punktfunk.yml`
exports `GAMESCOPE_WAYLAND_DISPLAY=gamescope-0` because the vendored
gamescope WSI layer reads that variable and nothing else to decide
whether to negotiate HDR10 (e1adc5d6, 2026-08-05, shipped in v0.25.0).
So inside the sandbox it is set on every launch, on every desktop —
verified in the sandbox on .21, where the host has neither a gamescope
process nor a socket. The flatpak is the main Linux channel, so this hit
every flatpak user on every desktop since v0.25.0.

Fullscreen was only the visible half. The same reading also meant:

- a stream ignored `fullscreen_on_stream = false` (the env ORed straight
  past the flag the client resolves the setting into);
- the settings dialog swapped every dropdown for a subpage, the
  workaround meant for gamescope's unmapped popovers;
- the system-button "auto" policy picked Deck rules, handing Steam/QAM
  to a local Steam UI that is not there;
- the overlay-focus watcher took the gamescope path.

The variable has to keep being exported, so the fix is in what we accept
as proof. `pf_client_core::gamescope::under_gamescope()` is now the one
answer, and all six readers route through it: `WAYLAND_DISPLAY` settles
it whenever we have one (a desktop session in the sandbox still gets
`--socket=wayland`, so it names the desktop compositor — the same
mismatch the WSI layer bails on), and Gaming Mode, which runs apps as
X11 clients with no `WAYLAND_DISPLAY`, falls back to the named socket
actually existing.

Dropped the `SteamDeck` leg from the Gaming-Mode tests while here: it
says which MACHINE this is, so a Deck in DESKTOP mode was fullscreening
too. `is_steam_deck()` still wants machine identity and keeps it, but
now compares the value — Valve documents `SteamDeck=1`, and desktop
Steam exports `SteamDeck=0` into everything it launches, so the presence
check called every PC with Steam a Deck.

Verified on .21 (CachyOS, GNOME): `cargo fmt --all --check` clean; the
new `only_a_real_gamescope_counts` test passes; `cargo clippy -D
warnings --all-targets` green over pf-client-core, pf-presenter,
punktfunk-client-linux and punktfunk-client-session, with all four
confirmed really built in the log rather than skipped as fresh.
2026-08-31 01:12:33 +02:00
enricobuehler 37bb894856 Merge pull request 'fix(android): hide the phone-body rows on a TV' (#457) from fix/android-tv-phone-only-rows into main 2026-08-30 23:11:07 +00:00
enricobuehler 39697cdd68 fix(client/linux): hold the slot picker open with a real minimum width
The popover collapsed to a column a character or two wide. The earlier
guess — that a ScrolledWindow asks for its minimum unless told to
propagate the natural one — was only half of it: with the horizontal
policy set to Never, `min_content_width` is IGNORED and the scrolled
window propagates its child's minimum instead. The child is a ListBox
of AdwActionRows whose titles wrap, and a wrapping label's minimum
width is one word, so the whole popover shrank to that.

`set_size_request` is a minimum GTK cannot ignore. The natural width
still grows it to fit the rows, up to the existing ceiling.
2026-08-31 00:53:58 +02:00
enricobuehler a04c8cee2d feat(clients): the host card's circle carries the OS mark, not a letter
Which machine this is answers a more useful question than which letter
its name starts with, and the name is spelled out directly under the
circle anyway. The mark moves out of the status row, where it was the
smallest glyph on the card, and becomes its leading visual.

A host that advertises no OS chain — an older one — keeps the initial,
so nothing regresses to an empty circle.

GTK sets the icon on the Adwaita avatar, which needs show_initials off
first: the widget prefers initials whenever it is allowed to. The
generated background colour stays, and the symbolic mark recolors with
the theme like every other glyph there.

Windows has no theme-aware tint, so the bake changes colour with the
move: mid-grey was chosen to sit on a card face, and it read as smudged
the moment the mark landed on the accent fill. It bakes white now, and
taller, since it is a 22 DIP avatar face rather than a 16 DIP row glyph.
One bake, not two — the row was the grey one's only reader.
2026-08-31 00:51:24 +02:00
enricobuehler d2c2240d71 Merge branch 'main' into feat/console-pairing-toast 2026-08-30 22:44:44 +00:00
enricobuehler 6dbaafa764 fix(client/linux): centre what sits on a disc, widen the slot picker
Three layout faults in the GTK editor, two of them one mistake.

A gtk::Box is HORIZONTAL by default and packs a single child at the
start, so a child narrower than the box's requested size lands on its
left edge no matter how it aligns itself. That put the centre disc's
mark and the shortcut preview's chord against the left rim. Both fixes
are the same: let the child expand, so its own centre alignment has
space to centre within.

The slot picker opened at its 320 px floor because a ScrolledWindow
asks for its minimum width unless told to propagate the natural one —
which wrapped entries like "Disconnect, keep the game running" into a
narrow column. It now sizes to the list, between a floor and a ceiling.
2026-08-31 00:44:32 +02:00
enricobuehler 77d75c73c1 Merge branch 'main' into fix/tvos-overlay-pad-input 2026-08-30 22:44:31 +00:00
enricobuehler 5f4ca61627 Merge branch 'main' into fix/android-tv-phone-only-rows 2026-08-30 22:44:17 +00:00
enricobuehler b07e0c2d68 Merge branch 'main' into fix/macos-host-compile-holes 2026-08-30 22:44:06 +00:00
enricobuehler 959266cf47 Merge pull request 'fix(ci): unblock main — the wndproc abort and the stale openapi.json' (#460) from fix/tray-wndproc-poison-abort into main 2026-08-30 22:43:47 +00:00
enricobuehler 1ec673add8 fix(tray): a poisoned status lock stops aborting the tray process
`ci / rust` has been red on main: gate B of check-unsafe-hygiene.sh flags
`.unwrap()` on the status mutex inside `wndproc`, and it is right to. A
panic crossing an `extern "system"` boundary does not unwind, it ABORTS
the process — so a thread that panicked while holding this lock would
take down the host's only visible surface, on a box where the icon is
also how the operator stops the host.

Poisoning is not a reason to do that here. What the mutex guards is a
display enum; reading it after another thread died is harmless, and
`PoisonError::into_inner` hands it over — the idiom already used
elsewhere in the tree.

Routed through one `App::status()` rather than patched at the line CI
could see. The gate is lexical, so it flagged only the call written
inline in `wndproc`; the other four sit in helpers that `wndproc` calls,
where a panic unwinds into the very same boundary and aborts just the
same. Fixing only the visible one would have left four identical aborts
a function call away and made the gate look satisfied rather than the
bug fixed.

⚠ NOT verified locally: the tray cannot be type-checked for Windows from
a Mac (aws-lc-sys' build script cross-compiles C and dies in cc-rs long
before win.rs), and scripts/xcheck.sh does not carry punktfunk-tray.
windows-host.yml builds AND clippies this crate and triggers on
`crates/punktfunk-tray/**`, so CI is the verifier here.
2026-08-31 00:40:18 +02:00
enricobuehler a5593de13a chore: re-sync the docs-site copy of openapi.json
`check-docs-drift.sh` requires `docs-site/public/openapi.json` to be a
copy of `api/openapi.json`, and the regeneration in the previous commit
updated only the source. The docs site would otherwise publish a
description the API no longer carries.

Caught by the gate, which is what it is for. Its remaining output — the
`PUNKTFUNK_STANDBY_SINK_KEEP` baseline entry that c02c3ec1 documented
and did not prune — is informational and does not fail the job.
2026-08-31 00:31:08 +02:00
enricobuehler 9a0c03a02e fix(console): the icon test reads the table's third column
Adding the font codepoint to the shared table widened every ALL entry
to three fields; the console's parse-and-fit test still destructured
two. Test-only, so the plain build stayed green and only clippy
--all-targets caught it.
2026-08-31 00:26:35 +02:00
enricobuehler ce5cc10082 fix(clients): the slot picker drops its second way to do one thing
Both desktop editors offered "Swap with <clock>" rows beside the
catalogue. Dragging one disc onto another already swaps them, and any
slot can be set outright from the catalogue above — so the six rows
only made the list longer.

On Windows the card also carried a title naming the slot and a Done
button. The band above the ring names the slot being edited, the open
disc is ringed white, and a pick now closes the card, which is what the
GTK popover always did. Neither had anything left to say.
2026-08-31 00:24:42 +02:00
enricobuehler dfc10b68e4 fix(client/windows): the font conversion left two dead branches
Clippy caught both, and both are the conversion's own shadow: DIM_INK
lost its last reader when the ring's mark stopped being an opacity on
an Image, and the reset button's armed/unarmed icons collapsed into the
same call once the white bake was gone. The accent fill already says
the button is armed, and the glyph takes the on-accent brush itself.
2026-08-31 00:22:20 +02:00
enricobuehler b50248168b fix(client/windows): draw the icon font, stop rasterizing the icons
Baking PNGs was the wrong call and it showed twice over. Reactor's
`.icon()` builds a BitmapIcon with `ShowAsMonochrome(false)` and no
size, so a baked mark is stuck at ONE colour on every theme and renders
at its source pixel count read as DIPs — a colour that never matches
the theme, and a size each control template clamps or does not.

Draw Lucide's own icon font instead. A FontIcon is sized by the control
and tinted from the foreground brush, exactly as the SymbolIcon it
replaced was: white on an accent button, the theme's colour in the
sidebar, and a vector at any DPI. The ring's discs take the same glyph
through a TextBlock, where the size is the ring's geometry and the ink
is white because a disc is dark on both themes.

Three PNG bakes, the two-colour split, the LOCALAPPDATA materializer
and its cache all go. What replaces them is a codepoint per icon in the
shared table and one 664 kB font.

The font reaches the exe by two routes, because ms-appx:/// resolves to
the install folder either way: build.rs stages it for a dev build, and
it is checked in under packaging/assets for a shipped one, which
pack-msix.ps1 copies into the layout the MSIX, the installer and the
portable zip are all packed from. That script now fails loudly if it is
missing — every icon would otherwise ship as a private-use box.
2026-08-31 00:18:19 +02:00
enricobuehler 5ee6468b2b test(pf-vkdecode): one lock-proving helper replaces six set_var copies
CI gate C counts `env::set_var` mentions per file and this file was at
its baseline of five, so the field leg's sixth copy failed the build.
Raising the baseline is what the gate tells you not to do, and it would
have been the wrong answer anyway: the six copies were the same two-line
safety argument written out six times, which is precisely the shape a
ratchet exists to discourage.

`arm_test_readback` takes the GPU lock guard BY REFERENCE. `env::set_var`
is safe to call and unsound from a live multithreaded process, so "the
caller holds the binary-wide lock" is the entire safety argument — and a
borrow states it in the type system, where it cannot drift out of date,
instead of in prose repeated at each call site.

The file drops from six mentions to two (the call, and the doc comment
that names the API it is arguing about — gate C counts comments too, by
design, and writing around its grep would be gaming it). The baseline
moves 5 -> 2 to match, so the ratchet stays tight rather than merely
satisfied.

Gates: fmt, clippy --all-targets and 197 lib tests clean;
check-unsafe-hygiene.sh gate C now passes. Gate B's punktfunk-tray
finding is untouched by this branch and fails identically on main.
2026-08-31 00:17:47 +02:00
enricobuehler 3db1f53d89 chore: regenerate the stale api/openapi.json
`ci.yml` fails `mgmt::tests::openapi_document_is_complete_and_checked_in`
with its own instructions:

  api/openapi.json is stale — regenerate with:
  cargo run -p punktfunk-host -- openapi > api/openapi.json

Nobody could see it: the unsafe-hygiene gate sits six steps earlier in
the same job and has been failing since 0329afcb, which skips Test.

The drift is one line and no API surface at all. `7d61c968
feat(vdisplay/windows): standby sinks are neutralised by default`
rewrote the doc comment on `DisplayPolicy::pnp_disable_monitors` — that
comment IS the schema description — and did not regenerate the file.

Structurally the regenerated document is identical: no path, schema or
property is added or removed, and the only textual change is that one
description string. So this carries no platform flavour despite being
generated on macOS.

Refs #451
2026-08-31 00:16:30 +02:00
enricobuehler c2eea7d3cf fix(client/windows): the Lucide bake size IS the on-screen icon size
Every button's icon rendered enormous. Reactor's `.icon()` builds a
WinUI BitmapIcon and drops it into the control with no size set, and a
BitmapIcon measures at its source PIXEL count read as DIPs — where the
SymbolIcon it replaced self-sized to its glyph. The bake was 128 px, so
every button carried a 128 DIP mark.

It looked correct in the settings sidebar, which is what hid it: a
NavigationViewItem's template clamps its icon, an ordinary Button does
not.

Bake the button marks at 16 px instead, Segoe Fluent's optical weight
in a button. The ring keeps a large bake of its own, because it draws
through Image, which takes an explicit width and height — so a big
source there buys resolution rather than size.

The test asserts the ring bake stays much heavier than the button one:
collapsing the two back into a single bake is exactly the mistake, and
it is invisible until someone opens the app.
2026-08-31 00:07:43 +02:00
enricobuehler 2629508a10 fix(scripts): read framehash's bare hex digest, not a prefix that never existed
The extraction matched on `sha256=`, which ffmpeg's framehash muxer
does not emit: its data rows are `0, <dts>, <pts>, <duration>, <size>,
<bare hex>` and the algorithm is named once in a `#hash:` header. So
the reference file came out EMPTY and the script reported "ffmpeg=0
frames" — indistinguishable, to a reader, from ffmpeg having failed to
decode the capture at all. Take the last comma-separated field of every
non-comment row instead.
2026-08-31 00:00:59 +02:00
enricobuehler e7d650f7b6 test(pf-vkdecode): report which field AUs needed concealment
A capture taken over a lossy link holds AUs whose references never
arrived, and ffmpeg conceals those differently from us — so a divergence
at one of them says nothing about this decoder. Recording the AU indices
(and printing the verdict last) is what separates "our bug" from "the
field lost packets", which is the only reading that makes a field diff
actionable. Split out of a dropped commit whose other half was wrong.
2026-08-31 00:00:59 +02:00
enricobuehler dbb9d75718 test(pf-vkdecode): decode a field capture into per-frame hashes
The golden parity legs only decode streams our hosts never emit, and the
field defect they cannot see is exactly the one two Deck reporters now
show: a smear that accumulates through the reference chain while every
integrity counter stays at zero. The full client audit (wire, planner,
params conversion, slots, scope, ring, submission, caps/session) found
no defect by inspection, so the fix needs the first divergent frame off
real silicon.

This leg turns any PUNKTFUNK_DUMP_VIDEO capture into that number:
PF_VKD_FIELD_STREAM points at the .h265 (the .idx sidecar is honoured
when present, complete==0 lines skipped), the join point is found the
way the client found it (plan until an AU succeeds), and every
delivered frame is read back and hashed to <stream>.pfhash in display
order. Per-AU errors are tolerated and counted, off-size frames from a
mid-capture renegotiation are released unshown, and PF_VKD_FIELD_YUV
dumps named frames' planes for eyes-on inspection.

scripts/vkdecode-field-parity.sh is the other half: ffmpeg -f framehash
over the same bytes (nv12/p010le match the readback's packing exactly),
first divergent index, ffprobe facts for the divergent picture.
consume_frame now returns the planes and the hashing moved to its
callers, which is the whole extent of the change to the golden legs.
2026-08-31 00:00:59 +02:00
enricobuehler 53c7a9e171 fix(tray): survive a poisoned status lock in the wndproc
`ci.yml`'s unsafe-hygiene gate has been failing on main since 0329afcb:

  punktfunk-tray/src/win.rs:675: unwrap/expect/panic! reachable in
  extern fn `wndproc` (no catch_unwind)

It is right to. A panic crossing an extern boundary aborts the process
since Rust 1.81, so a poisoned status mutex would take the tray icon
away with no way to get it back — the failure the supervisor work in
that commit exists to prevent.

Every read of that mutex now uses the poison-tolerant form this
workspace already uses in 192 other places. Four sites, not the one the
gate named: `wndproc` also calls `update_icon`, `notify_on_connect` and
`show_menu`, and all three read the same lock the same way. The gate
scans only the extern function's own body, so it saw one of four
identical hazards.

The writer in `run()` is left alone. It is the poller thread's callback,
reached by no extern function, so a panic there unwinds normally.

Refs #451
2026-08-30 23:52:52 +02:00
enricobuehler 3cfb3df594 fix(client/windows): the quick-action editor loses its gradient stage
The stage was a diagonal purple-to-teal gradient, baked to a BMP on
disk because reactor's brushes are flat. It read as a different app,
and the console's editor had already dropped its own for that reason.
It is now a flat card face, and the bitmap writer goes with it.

The discs carried short words and no icons at all. They now draw the
slot's Lucide mark from the shared table, the centre draws the ring's
own `more`, and the name of the disc under the pointer reads out in a
band below the ring — the band the other four editors already have,
and where the name went when the words became marks.

Reset stops being a drop-down menu labelled "Reset…" and becomes the
console's armed row: "Reset to default", then "Press again to reset",
with the line about what it removes under it. Same two presses, same
words as every other client.

The rest of the shell's icons move from Segoe Fluent symbols to the
same Lucide set. WinUI cannot tint them: reactor builds every
BitmapIcon with ShowAsMonochrome(false), so each mark ships baked in
two colours — mid-grey for ordinary surfaces, white for accent-filled
buttons and the ring's dark discs.
2026-08-30 23:44:43 +02:00
enricobuehler 2cc5dea786 fix(client/linux): the quick-action editor wears the ring's own marks
The editor is meant to BE the ring, but its discs carried short words
where the in-stream ring carries icons, and it sat on a purple-to-teal
gradient the console's editor had already dropped as decoration. Two
different pictures of one control.

Discs now draw the slot's Lucide mark from the shared table, the centre
draws the ring's own `more`, and the stage is a flat card face like
every other card in this shell.

The rest of the shell's icons move to Lucide too, so one page does not
sit in a different icon language from the page before it. GTK strokes
the same path data with gsk and cairo that Skia strokes on the console,
in the widget's own CSS colour — which is why a mark on a ring disc
comes out white without a second asset: `.pf-ring-disc` says so.

Three status-page illustrations keep their Adwaita names: AdwStatusPage
takes an icon name or a paintable, and a paintable cannot read the
widget colour without a GtkSymbolicPaintable subclass — more machinery
than three decorative empty states are worth.
2026-08-30 23:44:34 +02:00
enricobuehler a2d90ad047 feat(clients): one Lucide icon set for every shell to draw from
The console already drew Lucide marks, but only the console: its path
table was private to pf-console-ui and its slot-to-icon map lived in
ring.rs, so the two desktop editors that configure the same ring could
not reach either. The GTK editor drew words on its discs and the WinUI
editor drew words too — three shells, three vocabularies for one ring.

Move both tables down into pf-client-core, where every Rust client
already looks for the ring's model, and derive them from masters in
assets/lucide so a mark cannot drift between shells.

The console's icons.rs keeps aliases only for the marks its own chrome
draws. A slot's mark is no longer among them: the shared table hands
out a NAME, which `by_name` resolves — one lookup rather than an alias
per slot, and no second list to fall out of step with the first.

The icon test widens from the 19 marks the console named to all 34 the
workspace ships, because two more shells now stroke the same strings
and neither has a parser of its own to fail loudly.
2026-08-30 23:44:25 +02:00
enricobuehler 7004aee28f feat(web): announce a pairing knock wherever you are
A device asking to pair showed up only on the Pairing page. Anywhere
else in the console nothing said it had happened, so a request waited
until someone thought to go and look — worst on a phone, where that page
is several taps away.

The host already publishes the knock as a `pairing.pending` event and
the console already subscribes, so this hangs a toast off the frame
that was arriving anyway. It names the device from the event's own
payload and offers one button through to the Pairing page.

The same event now also invalidates the pending list itself, which it
did not: it refreshed the status card and the pairing status, and left
the list an operator actually sits and waits on to its own 10 s timer.

The action navigates rather than routing: this module is not a
component and has no router to reach. It costs a page load, on a button
someone pressed on purpose.

Refs #294
2026-08-30 23:26:40 +02:00
enricobuehler f6f40f4dd3 fix(client/apple): let the pad drive two overlays on tvOS
On an Apple TV the wake prompt was a dead end: the host failed to wake,
and neither Cancel nor Try Again could be pressed with a controller.

`ConnectOverlay` and `TrustCardView` each mount a zero-size view that
owns a `GamepadMenuInput` for the overlay's lifetime, and both gated it
to iOS and macOS. That is not tvOS being driven some other way — the
console home underneath runs the same binding there, and it gates itself
inactive while an overlay is up, so with no binding in the overlay
nothing read the pad at all. The trust card had the same hole, between a
pad-only Apple TV and every unknown host.

`GamepadPrompt` keeps its exclusion: it replaces system alerts that the
tvOS focus engine already drives, which its own header says.

Both bindings need an EXTENDED gamepad, the same thing that turns the
console UI on, so this covers the takeover exactly. A Siri Remote is not
one and reaches these buttons through the focus engine or not at all;
whether focus lands in either overlay is unverified and not addressed.

Refs #453
2026-08-30 23:22:16 +02:00
enricobuehler 743489424d fix(android): hide the phone-body rows on a TV
An Nvidia Shield shows "Rumble on this phone" and "Gyro from this phone"
in Controller settings. Both rows are about this device's own body, and
a TV box has none — so on a Shield they are worded for hardware that
isn't there and promise something the box cannot do.

They were gated on the hardware alone, on the assumption that a TV
answers no to a default `Vibrator` and to a SensorManager gyroscope. A
Shield answers yes to both. The likely route is the attached controller
owning the vibrator and the gyroscope the queries find, but the form
factor is the honest gate either way.

Gated at the call site rather than inside `deviceBodyVibrator`: its two
other callers — the console's menu haptics and the in-stream mirror —
want exactly the vibrator it returns today, whatever the form factor.

Fixes #449
2026-08-30 23:17:36 +02:00
enricobuehler c5b554ffb9 ci: check that punktfunk-host still compiles on macOS
The third-platform seam has no gate, which is why it rotted: the
unguarded call sites went from 9 to 11 in the four weeks after they were
first counted, and each one was only found by someone running the check
by hand.

Its own workflow rather than a job in apple.yml, for two reasons. That
file's paths filter is deliberately narrow — punktfunk-core and the
Apple client — because the mac mini is shared and an xcframework build
is heavy, so a host-crate edit would never have fired it. And its
`distribute` job is `needs: swift`, where anything that later grows the
same dependency can block a TestFlight upload over a host compile hole
the client has no part in.

`cargo check`, not clippy, and no `-D warnings`: the graph carries code
that is dead on macOS, and failing on that would only teach people to
ignore the gate. It asks one question.

Refs #299
2026-08-30 23:13:11 +02:00
enricobuehler 18fec75d68 fix(host): compile punktfunk-host on macOS
`cargo check -p punktfunk-host` failed on macOS with 11 errors across
six files. Not one was missing functionality: every one was a call site
or a dependency that assumed Linux-or-Windows without saying so, and the
count had grown from 9 to 11 in four weeks with nobody working on macOS.

`opus` and `libc` were declared only for the platforms with a capture
backend, though the code reaching for them is portable — the mic pump
just decodes, and both `hooks.rs` helpers are already `#[cfg(unix)]`.
`pf-vdisplay` re-exported `try_recover_session` and
`cancel_pending_tv_restore` behind a Linux gate even though both already
carry off-Linux arms. `cursor_blend_for` split on Windows rather than on
Linux, so a third platform fell into the arm holding the VAAPI/CUDA
terms that exist only there. `gamelease` reached past `procscan`'s own
platform-neutral wrappers to `Scanner::system()`, which the module's
documented boundary says it must not.

That last one now goes through a new `procscan::alive`, which builds a
scanner per call exactly as the `names` and `resolve` wrappers beside it
do — two syscalls on Linux, a unit struct on Windows.

Linux and Windows behaviour is unchanged.

Refs #299
2026-08-30 23:13:01 +02:00
enricobuehler 221d05500b Merge pull request 'fix(ci): the ABI pin, android dead code, windows gamescope compile' (#455) from touch/dial-model into main
Reviewed-on: unom/punktfunk#455
2026-08-30 20:56:02 +00:00
enricobuehler 50b1be3d36 Merge pull request 'fix(host): release lingering displays before a power action' (#454) from fix/release-display-before-suspend into main
Reviewed-on: unom/punktfunk#454
2026-08-30 20:55:21 +00:00
enricobuehler c02c3ec11b Merge pull request 'docs(configuration): document PUNKTFUNK_STANDBY_SINK_KEEP' (#452) from fix/docs-drift-standby-sink-env into main
Reviewed-on: unom/punktfunk#452
2026-08-30 20:54:00 +00:00
enricobuehler 5f786267dd fix(client): android and windows compile their own truth
Two platform breaks from the branch's first full CI run, invisible to
the local gates.

Android's cargoNdkClippy rejected the ring's in-stream surface as dead
code, and it is right: the Android console holds the ring solely as
the editor, so `input`, `take_command`, `take_cmds`, `damage`,
`animating` and `key` (and the shell's `send_cmd`, and
`MenuList::animating`) are driven only by the desktop overlay. They
wear `cfg_attr(android, allow(dead_code))` rather than a cfg — cfg'ing
them out cascades into their parameter types' imports — and the
`host_actions::{self}` import shrinks to the one name every platform
uses, with the desktop-only call fully qualified.

Windows failed on the two `overlay_focus::gamescope_session()` calls
T17 and the touch-devices log added: `overlay_focus` exists only on
Linux (there is no gamescope to be inside anywhere else). Both go
through a new `in_gamescope()` that answers `false` off Linux.

Verified: the container gate green (64 / 242 / 227); clippy
`-D warnings` for pf-presenter and the Windows client on the Windows
box. The Android lints have no local run (cargo-ndk clippy dies in
audiopus's cmake on a Mac) — the fixes answer exactly the four errors
in the CI log, and CI is the check.
2026-08-30 22:43:32 +02:00
enricobuehler 9bea075791 fix(core): the ABI pin says 28, as the v28 bump demanded
`abi_version_is_pinned` still asserted 27. The v28 bump (the touch
capability, `punktfunk_connection_host_caps2`) arrived in Phase 0
without the pin update the test's own comment demands, and no gate on
this branch ever ran punktfunk-core's tests — the PR's first full CI
run is what caught it. The pin now says 28, with the v28 line beside
it. Container tests: punktfunk-core 512 green, the pin suite included.
2026-08-30 22:43:30 +02:00
enricobuehler 2532e8a53c Merge pull request 'fix(windows): supervise the status tray, and stop the host on exit' (#451) from fix/windows-tray-supervisor into main
Reviewed-on: unom/punktfunk#451
2026-08-30 20:42:02 +00:00
enricobuehler 5fc425b24b Merge pull request 'feat(client): the quick-action ring, editors and virtual controller' (#448) from touch/dial-model into main
Reviewed-on: unom/punktfunk#448
2026-08-30 20:41:14 +00:00
enricobuehler fd050bbc67 Merge pull request 'fix(client): the six Phase 0 touch defects' (#447) from touch/phase-0 into main
Reviewed-on: unom/punktfunk#447
2026-08-30 20:38:45 +00:00
enricobuehler 76513cdc6f fix(host): release lingering displays before a power action
A power action ended every session and waited one second before asking
logind, but that teardown is asynchronous: the stream loops have to
notice the quit flag and drop the display lease, and the same wait gets
1500 ms in native/handshake.rs. Nothing verified the display was gone.

Going under on top of a live display is not a transient. The linger
deadline is a std::time::Instant, and CLOCK_MONOTONIC does not advance
while the box is suspended, so the box wakes with the stale display
standing and its window unspent. On a SteamOS managed takeover that is
a headless gamescope session holding the box's own panels DPMS-off.

Pinned displays are exempt. KeepAlive::Forever means until host
shutdown or an explicit release, and on gamescope's bare spawn that
keep-alive covers the nested session and its game, so releasing a pin
here would kill a running game on every sleep.
2026-08-30 22:25:10 +02:00
enricobuehler 3a6f594754 fix(ci): the touch cap and the standby-sink knob join the env baseline
The docs-drift gate flags PUNKTFUNK_* names in code that the docs never
mention, and this branch carries two: `PUNKTFUNK_HOST_CAP2_TOUCH`, the
v28 ABI capability bit — a quoted literal only because cbindgen.toml
renames the C constant, not an env var anyone sets — and
`PUNKTFUNK_STANDBY_SINK_KEEP`, the vdisplay standby-sink neutraliser's
operator opt-out, which is not this branch's feature to document. Both
take the gate's own escape for deliberately internal knobs: a line in
scripts/ci/docs-undocumented-env-baseline.txt. The check runs green
locally with them.
2026-08-30 22:19:03 +02:00
enricobuehler ac7fa7e260 feat(console): Lucide icons on the dial; the glyphs speak one language
"All text looks boring and ugly" — the dial's discs carried short words
because the console has no icon font. It still has none: the icons are
Lucide v0.462.0 (ISC), each one carried as its 24×24 SVG path data in
`icons.rs` — every shape of the source SVG folded into one path string
by scripts/gen-lucide-icons.py (a leading relative `m` is absolutised,
since concatenation would re-anchor it) — parsed by Skia's own SVG-path
parser and stroked at Lucide's native width 2 with round caps, scaled
to whatever box the caller gives. A designed, consistent set, rendered
exactly as drawn, for a page of string constants: no font ships, no
dependency lands, and every mark takes the console's paints.

On the dial: End stream, Leave, Touch mode, Keyboard, Stats, Mic (and
its struck twin while muted), Virtual controller, Send text, the three
host powers (moon, cycle, power) and the centre's ellipsis. A shortcut
keeps its stacked keycap chord — its content IS text — and an unknown
host action falls back to its label, so nothing the set cannot name
goes blank. An empty slot in the editor shows a faint plus; in-stream
it stays plain glass.

The hint bar's gamepad glyphs joined the same language, because they
were three dialects: solid floating triangles beside badged letters,
two ink alphas, three container treatments, and a keycap hairline that
never scaled. Now the directional marks are the set's chevrons (a
direction is not a button, so they stay badge-free), the remote's back
arrow is the set's return mark inside its badge, every container wears
the same scaled hairline, and the PlayStation shapes grew to match the
letters' weight, round-joined.

Licensing: LUCIDE-LICENSE sits in the crate and the notices generator
lists it as vendored third-party source; THIRD-PARTY-NOTICES.txt is
regenerated, which also catches the file up with this branch's real
lockfile (nine crates left, chacha20 moved — the committed baseline
was stale).

Tests: every icon parses and stays inside its 24-unit box; every
built-in slot maps to an icon while shortcuts and unknown host actions
stay text.
2026-08-30 21:49:52 +02:00
enricobuehler 807bb3492a fix(console): no second card behind the dial; the phone-size ring
Two from the Nothing Phone's gamepad UI, both scale bugs the Deck's
scale of 1 could never show.

The "second fill with a different border radius" behind the dial was
the stage's focus halo. `focus_halo` takes its corner in DESIGN units
and scales it itself, like `panel` — every other caller passes it that
way — but the editor handed it an already-scaled corner, so the halo's
rounding grew with the SQUARE of the display scale: identical to the
stage at scale 1, a visibly rounder ghost card at the phone's ~2.

The dial was tiny because the fit reserved 140 design units for the
shortcut rows below the stage. A phone's console scale is
max(min(w,h)/800, density·0.75) — landscape leaves ~380 units of
content height, and the reservation crushed the ring to about half
size. Now, when the fit-below falls under 0.75 and the screen is wide
enough, the shortcut rows move BESIDE the stage and the ring scales to
the height instead — near full size on a landscape phone. The couch
layout is untouched: its fit-below is over 1, so the rows stay under
the stage exactly as the Deck shows them.
2026-08-30 21:39:45 +02:00
enricobuehler 21be74b4b7 fix(console): the picker dims the whole screen; the ring always fits
Two more from the Deck.

The picker's scrim stopped at the content rect, so the heading above it
stayed bright — a band instead of a modal. The shell only translates
the canvas (insets, transitions), it never clips a screen to its rect,
so the scrim now overshoots every edge by more than any heading, hint
bar or inset can be. The hint bar draws after the screen and stays
legible; it already carries the picker's own controls while it is up.

The dial was cut off at the top — and the cause was the overshoot
clamp of the previous commit, not the stage. The stagger normalisation
divides the per-slot lag out, so `q_raw` sits ABOVE 1 for the early
slots whenever `shown` is near 1; the old code clamped travel at 1, the
new clamp at 1.15 parked slots 0–2 up to 15 % past their seats FOR
GOOD, and the 12 o'clock disc rode out of the stage's clip. Only the
spring's excess past 1 overshoots now (`q + (shown − 1).max(0)`), so at
rest every disc sits exactly on its seat, in the stream and in the
editor alike; the centre's pop follows the same rule.

And for every window the stage cannot fit at full size: the editor
computes a fit from the rect's height (keeping room for the caption and
two shortcut rows) and the stage's width, scales the stage by it and
draws the ring at the same reduced scale — so the ring is never
clipped, whatever the screen; on the Deck the fit is 1 and nothing
changes.
2026-08-30 21:21:33 +02:00
enricobuehler d70e6f64ef fix(console): the ring animates, the sheet fills, the editor is rows
Five things the Deck showed in one session, and one it could not.

The sheet opened empty. The stream overlay redraws only when
`Ring::damage` changes, and that key held only discrete state — the
sheet's `MenuList` fans its rows in on a mount entrance, its focus
eases and springs, none of which the key knew about, so the rows sat at
their first frame (unrisen, invisible) until a cursor move forced one
more redraw. The same gate quietly stalled the ring's own arrival short
of 1 once its steps fell under the key's quantisation. `MenuList` now
reports `animating()` from a settled flag it computes at the end of
every render (no entrance, every ease landed, every spring at rest, the
scroll where it wants to be — the eases are landed explicitly, an
approach never arrives on its own), and `Ring::animating()` folds it
in with its own spring, its highlight eases and its wind-in; while any
of that is true the key hashes a frame counter that `tick` advances, so
the overlay draws every frame until it has all landed and then goes
back to costing nothing.

With the loop fixed the ring could be made to look like something. The
arrival is a spring (`springs::RING`, a whisker of overshoot the six
discs carry into their travel and their size, staggered as before), the
discs sit on a soft shadow with a rim of light on their top edge, the
pad's highlight is a glow and a ring at an eased amount per disc so it
travels between them instead of snapping, the scrim is a pool of shade
around the ring thinning to a veil, the label rides in with the ring,
the sheet rises into its seat under a shadow while its rows fan in, and
a closed ring winds back in and fades instead of vanishing — closed for
input at once (`open`), drawn a moment longer (`visible`). Reduce
motion keeps the crossfade it had. The theme owns the four new paints.

The editor handed focus to the shortcut list when the stick pointed at
6 o'clock: the walk fired on any `Move(Down)` at slot 3, and the stick's
own move rides behind its sector in the same sample. It now walks on a
D-pad Down there, or on the stick pushed down a second time once its
first push put the highlight there; never on the move behind a sector
(`stick_engaged`), and the move that follows a sector-walk is swallowed
so the list is not stepped twice. Coming back up, the ring adopts a
still-held stick so its repeats step nothing until it lets go.

The shortcut editor was its own thing — chips, a hand-drawn grid, a
name box — with a focus halo nothing else on the shell uses, which on a
Deck is invisible. It is now the console's form grammar: a `MenuList`
of rows (Name, Key, Hold with Ctrl/Alt/Shift/Win as stepped On/Off
rows, Add/Save, Remove) under the disc as the ring will draw it; the
name opens the keyboard tray as the add-host screen does, the key opens
a key tray of the same shape — the 66 keys laid out like a keyboard,
drawn the keyboard tray's way: flat keys, the focused one filled with
the accent, the chosen one washed with it. Row focus is the list's own
lift and tint; key focus is a solid accent face.

Touch on the Deck: the user's twist did nothing in Gaming Mode, and the
log shows why it cannot be diagnosed yet — no T17 notice, no finger,
nothing. The presenter now logs SDL's touch devices (id, kind, name) at
connect, the first direct finger of a session, and once, a finger from
a non-direct device it ignores. §5.5 stays unverified; the handoff
carries the evidence and the question.

Tests: the ring's damage key changes per tick while animating and not
once settled; the shortcut rows follow the draft; the key tray walks
like a keyboard. The grid, draft and column tests stay.
2026-08-30 20:47:53 +02:00
enricobuehler 3d46a21381 docs(configuration): document PUNKTFUNK_STANDBY_SINK_KEEP
The standby-sink opt-out shipped in 7d61c968 with no docs-site page owning
it, so the docs-drift ratchet in check-docs-drift.sh has failed on every
commit since. It is an operator-facing knob, not an internal one, so it
belongs in the Windows host table rather than the undocumented baseline.
2026-08-30 19:48:17 +02:00
enricobuehler 560d91a8f6 Merge pull request 'fix(client): show the access chip only with the stats overlay' (#450) from fix/access-chip-stats-gate into main
Reviewed-on: unom/punktfunk#450
2026-08-30 16:53:55 +00:00
enricobuehler 0329afcb58 fix(windows): supervise the status tray, and stop the host on exit
The tray died on every upgrade and stayed dead until the next sign-in. The
update-specific remedy only covered console-initiated updates, and only when
the previous binary already recorded the intent — winget, a hand-run setup and
a plain crash all still ended the same way.

Worse, the relaunch it did manage joined the service worker's kill-on-close
job object, because spawn_in_active_session never asked to break away. Every
process that call launches — the tray, the user's game, a hook — was therefore
reaped when the service stopped, contradicting its own documented contract.

The host now supervises the tray for its whole lifetime, gated on the HKLM Run
value the trayicon task writes. That covers every way a tray can die and needs
nothing from the version that ran before, so the intent record's
tray_was_running flag goes.

The icon's lifetime tracks the host's in both directions: the menu's exit
entry stops the host, and says so. Only that entry does — a sign-out and the
uninstaller's --quit still leave a headless host running. The uninstaller now
removes the service before the tray, so the supervisor cannot put one back.
2026-08-30 18:07:48 +02:00
enricobuehler 0c4d503b09 fix(console): the stick points at a ring slot; the editor's stage fits
Three things the Deck showed in the gamepad UI's Quick actions editor.

The stick stepped the highlight one slot per flick, like a list. The
design (§2.6, D12) wants the weapon-wheel idiom: the stick's angle is
the slot, neutral is the centre, the D-pad steps. The stick only ever
reached the ring as a four-way `MenuEvent::Move`, quantised inside
`MenuNav::poll`. `menu_nav::ring_sector` now reads the raw sample: past
the deadzone by magnitude (a diagonal counts) the angle falls into one
of six 60° sectors centred on the slots; an engaged sector holds until
the stick drops under `MENU_RELEASE` or the angle leaves it by 5°, so a
stick resting on a boundary never flickers between two slots. The poll
emits `MenuEvent::Sector(Option<u8>)` on change, BEFORE the same
sample's buttons and `Move`, so the ring has engaged the stick by the
time the move that would have stepped it arrives and an A in the same
sample lands on the slot the stick points at; a sector the snapshot
adopted silently releases silently. While engaged the ring ignores `Move`
(the stick's own repeats included) and the D-pad steps again once the
stick lets go. In-stream, neutral highlights the centre; in the editor
the centre is inert, so neutral leaves the slot where it is. Every
list ignores the new variant through its wildcard arm; Android maps
integers INTO `MenuEvent` and is untouched. Android and Apple still
step four-way — their routers quantise the stick before the ring sees
it — and owe the same event.

The stage under the ring was a purple-to-teal gradient. It read as
decoration on the Deck, so it is the flat card face every other card on
the shell uses; `theme::stage_gradient` is gone.

The label under the ring — the highlighted slot's name, or the editor's
"press A" hint — sat at centre + radius + slot diameter, 328 units into
a 330-unit stage clipped to its rounded rect, so only its top edge
showed. The stage now sizes itself from the ring's own geometry (the top
slot, the bottom slot, `ring::LABEL_H` under it, one pad each side) and
the ring sits one pad below the stage's top, so nothing of the ring is
ever clipped. The caption says the stick points.

Tests: `ring_sector` for the six centres, the magnitude deadzone, the
overlap hysteresis and the release threshold; the poll's ordering and
change-only emission; the ring's stick/D-pad handover in-stream and in
the editor.
2026-08-30 15:41:49 +02:00
enricobuehler dda1e1e9c7 feat(client/windows): the quick-action ring's editor, native in WinUI
Design §3.3 in the Windows client, a Quick actions section of the
settings page drawn with the reactor's own elements. Six discs sit on
the ring's geometry over a gradient stage in a `Canvas`; the stage's
pointer handlers own the carry (a pointer that leaves a disc stops
reporting to it, so the discs cannot), a click on a disc opens its
picker, a disc carried onto another swaps the two. A row of six
buttons under the ring gives a keyboard and a screen reader every
slot the pointer reaches. The picker is a card: the catalogue by
group, the current pick accented, an entry this shell cannot serve
disabled with its note, and "Swap with…" as a menu.

The shortcuts follow: a row each with its keycap, Add, and Reset
behind a menu (the second press). The editor: the disc as the ring
will draw it, the name, the four modifiers as toggles, the key on a
keyboard-shaped grid of toggles — or "Press the chord…", which arms an
accelerator per key and Ctrl/Alt/Shift mix and fills the chord from
the one that fires (the Windows key stays a toggle: the shell never
sees a chord Windows owns). Save, Remove, Cancel.

Every edit commits at once through the page's `commit`, so the
profile scope's absorb records the whole ring (D10) and the page's
override marker shows it. WinUI's brushes here are flat, so the stage
gradient is a 24-bit BMP written once beside the art cache and loaded
as a file URI, with a flat tint if it cannot be written.

Gates: clippy `-D warnings` and the section's unit tests on the .133
Windows box. Not yet on glass.
2026-08-30 14:57:37 +02:00
enricobuehler d9787009c8 feat(client/linux): the quick-action ring's editor, native in GTK
Design §3.3 in the GTK shell, on the toolkit's own widgets so its
focus, drag-and-drop and screen reader carry the editor. A Quick
actions row on the Input page opens a subpage: six round buttons on
the ring's own geometry over a gradient stage, the inert centre, and
the name of the button under the pointer or the focus. A click picks
what the button holds from a popover — the catalogue by group, each
entry's availability note, the current pick marked, and "Swap with…"
for a keyboard; a button dragged onto another swaps the two (GTK4
`DragSource`/`DropTarget`, the slot as a `u32`). Under the ring the
shortcuts as rows with their keycap, Add shortcut, and Reset to
default behind an alert.

The shortcut editor is its own subpage: the disc as the ring will
draw it with its legend, the name as an `EntryRow`, the four modifiers
as toggles, the key on a keyboard-shaped grid of toggles — or "Press
the chord…", which takes the next real key press and fills the
modifiers and the key from it (`gdk::Key` → the chord's names). Save,
and Remove for an existing one.

The blob lives in the dialog's state and is written with the other
rows when the dialog closes, so it obeys the dialog's scope model: in
a profile the row carries the override marker on the first edit,
`commit_profile` writes the whole ring (D10), and the reset puts the
inherited blob back. The row's subtitle is the ring in order.

Gates: container fmt, clippy `-D warnings` and build for
`punktfunk-client-linux`; its tests under Xvfb, with two new ones for
the key names and the summary. Not yet on glass.
2026-08-30 14:54:16 +02:00
enricobuehler a45ba525f7 refactor(client): the ring's catalogue, geometry and shortcut edits are one model
Three desktop editors of the ring are about to exist — the Skia
console's, the GTK shell's and the Windows client's — and design
tenet 3 says share the model, not the renderer. So `pf-client-core`
now owns what every editor was restating:

- `overlay_actions::catalogue(cfg, platform)`: the groups, entries and
  availability notes an editor lists, in one order, with the desktop's
  notes (no virtual controller, no typed text) where the phones have
  none.
- `ring::{RING_RADIUS, SLOT_DIAMETER, CENTRE_DIAMETER, slot_angle_deg,
  slot_offset}`: the ring's geometry at 100 %, so three drawings of it
  cannot drift apart (tenet 8).
- `OverlayConfig::{upsert_shortcut, remove_shortcut}`: a new shortcut
  takes the next id and the first empty slot; an existing one changes
  in place; a removed one empties its slot — the same on every client.

The console's editors read these instead of their own copies; the
tests moved with the code.
2026-08-30 14:52:58 +02:00
enricobuehler ad543cf6b4 fix(client): show the access chip only with the stats overlay
The access chip stood for the whole of any limited session, at every
stats tier including off. A pill that never goes away reads as
distraction rather than information, and the fact it carries — what
this session may do — is one you check occasionally, not one you watch.

Gate it on the stats overlay in the three clients that draw it: the
presenter (and so the Linux and Windows clients, which spawn it),
Apple and Android. tvOS already stated it as a line inside the stats
HUD and needed no change. The expiry toasts stay at every tier — they
announce a change rather than describe a state.

On Apple the stats tier joins the badge stack's animation list, or the
chip pops on the toggle the way the motion hint used to.
2026-08-30 14:27:57 +02:00
enricobuehler 4ca580755a feat(console): the ring editor is the ring, and a real shortcut editor
Design §3.3 on the Skia console, done the way the phones have it. The
six "Ring, N o'clock" rows stepped with ◀ ▶ and the form that stepped
a key name through sixty entries are gone; one Quick actions row opens
`RingEditorScreen`: the in-stream `Ring` — the same type, in a new
editing mode — full size over a gradient stage. With a pad, the stick
or D-pad walks the slots, A opens the catalogue by group (Session,
Input, View, Audio, Host, Shortcuts, Empty, each with its availability
note and the current pick marked), Y lifts a disc and A drops it on
another to swap, B puts it down or goes back. With a pointer, a click
on a disc picks, a carry onto another disc swaps — `PointerKind::
Release` finally has its gesture. Under the ring the shortcuts sit as
rows with New shortcut and Reset to default (two presses); Down past
6 o'clock walks into them, Up from the top walks back.

`Ring` gains `edit_at` and an `Editing` state: no scrim, no idle
close, the centre dimmed and inert, the three power slots previewed
as a host that offers them would show them (a dimmed "does not offer
it" lied about the slot), a lifted disc raised, a carried disc under
the pointer, and `EditEvent::{Pick, Swap}` out through `take_edit`.
Nothing fires. The one desktop-only thing it read, the host-action
cache, is behind a cfg now, so the ring — and with it the editor —
compiles for the Android console too, which is the pad editor there.
Shortcut discs draw as stacked keycaps (the modifiers small on top,
the key large under them) in-stream and in the editors alike.

`ShortcutEditorScreen` replaces `RingShortcutsScreen`: the disc as
the ring will draw it with its legend, the name on the on-screen
keyboard (Steam's on a Deck), the four modifiers as chips, and the
key on a keyboard-shaped grid — every name `key_vk` knows, six rows
laid out as a keyboard lays them, walked with the stick (Up and Down
keep the column) or clicked — then Save, and Remove for an existing
one. A new shortcut still takes the first empty slot.

Left as it was: the console edits the global blob only (D9's scope
picker is still owed), and a swap on the console has no spring — the
discs change in place.

Gates: container fmt, clippy `-D warnings`, `pf-console-ui` 221 tests
(the ring's two editor tests, the catalogue, the draft round trip,
the grid's coverage and column rule; `every_row_has_exactly_one_tab`
55 → 48); the Android kit build through cargo-ndk, which compiles
this crate for the Android console. Not yet on glass.
2026-08-30 13:54:08 +02:00
enricobuehler 3052e560a9 fix(client): Gaming Mode's touch-as-mouse no longer walks the cursor (T17)
Design §5.5: on a Deck in Gaming Mode, Steam Input owns the
touchscreen and replays it as a mouse whose "relative" deltas are
absolute positions — a field capture read 0 finger events against 341
mouse motions of 300 to 450 px. Under the stream's relative-mouse lock
every one of those walked the host cursor further into a corner, and
since SDL saw no fingers, all three touch models were dead together.
`SDL_TOUCH_MOUSE_EVENTS=0` never reached them: they are Steam's, not
SDL's.

`SteamTouchMouse` (touch.rs) recognises that shape — a gamescope
session (`overlay_focus::gamescope_session`, now pub), no direct-touch
finger yet this session, a delta of 150 px or more that no finger drag
produces — and the presenter drops those motions instead of summing
them. The first one raises the session notice once: "Steam Input is
sending the touchscreen as a mouse — pick the Punktfunk controller
layout for touch". A real mouse on a docked Deck keeps working (its
deltas are small), and the first real finger to reach SDL switches
the guard off for the session.

The lever the notice names is the one the Deck docs already teach:
the Steam Input layout the Decky plugin installs, whose whole job is
real touch. §5.5 still marks it unverified on glass — the docs gain a
line on what happens without it, and the notice is the reminder. T18
(reading the touchscreen from evdev) stays deferred.

`build-flatpak.sh` gains `BRANCH=` so a Deck tracking the hosted
`canary` can take a test build in place of it; a second branch beside
it would win the plain `flatpak run` the plugin issues.

Gates: `cargo test -p pf-presenter --lib touch` on the Mac (19, the
new `gaming_mode_drops_leaked_touch_positions_until_a_finger_is_seen`
among them); container build + clippy `-D warnings` for `pf-presenter`
and `pf-client-core` and their tests; fmt. Not yet on a Deck.
2026-08-30 13:07:07 +02:00
enricobuehler 8b45652e12 feat(client/apple): the virtual controller on iPhone and iPad (T15)
Design §4 on Apple, matching Android: the ring's Virtual controller
slot toggles a controller over the stream. While it is up the session
model holds one `VirtualPadWire` — the lowest free wire index from
`GamepadManager`'s one allocator (the way `Sc2Capture` claims one),
declared an Xbox 360 pad, its Arrival before any input, held state
flushed and its Remove on close — so the host sees one controller
arrive and one leave beside any real pad (§9). The ring's mask reaches
it through `setRingOpen`: everything held is released on the host
when the ring opens, nothing is sent until it closes, nothing is
replayed after. Toggled from the ring, it starts masked.

The layer is UIKit: SwiftUI has no multi-touch, and UIKit keeps a
touch with the view it began in, which is exactly the ownership a
stick needs. Each control is one `PadControlUIView` placed by SwiftUI
at its preset position; the layer draws nothing between them, so a
finger there never reaches it and falls through to `StreamLayerUIView`
— tap-to-click keeps working beside the pad. The geometry and the
three input rules (`padControls`, `dpadBits`, `stickWire`,
`triggerWire`) live in the kit with the same numbers as the Android
`VirtualPad.kt`, so the two pads feel the same: four face discs in one
view (a thumb rolls from A to B), an eight-way D-pad by angle, a stick
that follows its first finger from wherever it landed, a trigger read
off the finger's position down its pill. A light impact per press.

tvOS keeps the slot dimmed — "Apple TV has no touch screen" — the way
the Touch mode slot is; the layer file is `#if os(iOS)`.

Editor: a Virtual controller section under the ring — Layout picker,
Opacity and Scale sliders that write on release — through the same
`blob` binding the ring uses. The picker's note no longer promises a
later release.

Gates: `pf-touch-apple-check.sh ios|tvos PunktfunkClient` both
`swift exit: 0`; `VirtualPadTests` (the Swift twin of the Android
test) written in step but not run — the bundle holds the 5-slice
iOS/tvOS core and the macOS test host needs it rebuilt. The Release
xcodebuild for the iPhone succeeded, installed and launched on the
device — not yet seen on glass.
2026-08-30 12:48:47 +02:00
enricobuehler da73d2e973 docs(client): the quick-action ring and the virtual controller
Neither had a page. `input.md` gains "The quick-action ring" — the
twist, the per-shell openers, `Select+A` — and under it "Virtual
controller": where it is shown from, that the host sees a real
arrival and removal on the next free pad index, the fall-through
rule, how the sticks, D-pad and triggers read a finger, and where the
three settings live. `client-settings.md` gains the Quick actions and
Virtual controller rows with their availability (the controller is
Android and iPhone/iPad only; Apple TV, a Deck and the desktops say
why not), and stops counting the Input section's rows — it said five
and listed ten.
2026-08-30 12:47:26 +02:00
enricobuehler acd61b31da feat(client/android): the virtual controller over the stream (T14)
Design §4 on Android: the ring's Virtual controller slot is a toggle
that draws a controller over the stream and hides it again. While it
is up it holds one wire pad on the router — `openExternal(XBOX360)`,
the slot model the capture links already use — so the host sees one
controller arrive and one leave, on the lowest free index beside any
real pad (§9). Its buttons ride `slotButton`, so the chords (`Select+A`
opens the ring, the exit hold, the mic and stats chords) and the ring's
mask work on it exactly as on a real pad.

Every control is its own pointer-input node above the gesture layer
and below the ring: a finger on a control is taken, a finger between
them never reaches the layer and falls through to the touch mode
beneath, so tap-to-click keeps working beside the pad. The face
buttons are one node of four discs (a thumb rolling from A to B
presses B); the D-pad reads eight ways by angle; a stick is owned by
its first finger and follows it from wherever it landed, with a fixed
dead zone; a trigger reads the finger's position down its pill, so a
slow press is a slow press. A tick per press. Three presets with fixed
positions; a narrow layer (a phone upright) stacks the clusters
instead of spreading them. Select, Guide and Start are on every
preset — a pad that cannot pause is a bug.

Router: `ExternalPad.axis` is dropped while the ring owns the pad,
like a button. `setRingOpen` had zeroed the axes on the wire, and a
stick still held under the ring wrote over that; a capture-link stick
had the same leak.

Editor: a Virtual controller group under the ring — Layout (Full,
Sticks and shoulders, D-pad and face buttons), Opacity and Scale
sliders that write on release — through the same `onChange` the ring
uses; `SettingDropdown` goes internal for it. The picker's note no
longer promises a later release.

Left out on purpose: stick clicks (no control for them), a
drag-to-position editor, per-game layouts, gyro and touchpad
emulation (§4.3). The pad is per session and starts hidden.

Gates: `:kit` + `:app` compile through cargo-ndk; `VirtualPadTest`
5/5 (every preset fits and never overlaps at three sizes, the D-pad
sectors, the stick travel, the trigger pull) and `OverlayActionsTest`
6/6; the debug APK is installed on the Nothing Phone, which was locked
— not yet seen on glass.
2026-08-30 12:47:16 +02:00
enricobuehler fa0d138df9 fix(client/android): the editor's ring opens at the stage's centre
The first open used a centre computed at composition, one step behind the size state, so the ring opened at (0, 0) and the clamp pinned it to the margin — up and left of the card by a slot radius. Both effects that open the ring read the live size now. Verified by screenshot on the Nothing Phone.
2026-08-30 12:16:14 +02:00
enricobuehler 6621e5adb1 fix(client): the swap settles without a flash; Android's ring is carded
The swap's reset fired at 450 ms, inside the spring's settle, so the
two discs jumped their last few points as the contents swapped — a
flash. The spring is stiffer (response 0.35 / damping 0.82 on iOS,
StiffnessMedium / 0.82 in Compose) and the reset waits 650 ms, past
the settle, on both.

Android: the ring is in a card again like every other field — an
`OutlinedCard` without the group's 16 dp inset, so the stage keeps the
width the ring needs and stays centred; the caption sits under it.

Gates: Android debug APK on the Nothing Phone; Release xcodebuild on
the iPhone.
2026-08-30 12:11:35 +02:00
enricobuehler 4f59fd4267 feat(client): a swap in the ring editor plays as a spring
Dropping a disc on another slot swapped the two contents in place. Now
the two discs travel to each other's slots on a spring (the dragged one
from wherever it was released); once they land the blob is written and
the drawing order snaps back to identity with the contents swapped, so
that write draws nothing. iOS keeps a per-disc `order` animated with
`.animation(value:)` and resets it in a transaction with animations
off; Compose animates each disc's position with a spring only while a
swap plays and a snap otherwise, so the twist and a drag still follow
the finger.

Two looks on the editor's stage, both platforms: the ring's scrim (18 %
black while open) is off in the editor — it read as a fill — and a
stage narrower than two ring margins centres the ring instead of
pinning it to one side (the clamp assumed a screen). Android's stage
stands outside the settings card, with its caption under it: the
card's inset left it narrower than the ring, and a card around a
control that draws its own discs read as a fill too.

Gates: Android debug APK on the Nothing Phone; Release xcodebuild on
the iPhone.
2026-08-30 12:08:55 +02:00
enricobuehler 22cba0cac1 fix(client): the ring editor's drag on Android, and four looks
On glass, five things about the quick-actions editor:

- Android: dragging a disc onto another slot did nothing. The twist
  surface sat on the PARENT of the ring, and the stream's touch
  handler consumes every change it sees, so the discs' drag detectors
  only ever saw consumed moves and cancelled; a tap survived because
  it has no moves. The surface is a sibling under the ring now, the
  way the stream's gesture layer is under the in-stream ring.
- The centre disc was live in the editor on both platforms, opening a
  preview sheet of something that is not editable there. It is drawn
  dimmed and inert while editing.
- The "Tap a button to change it…" line sat on top of the discs. It
  is the field's caption under it now on both platforms, like every
  other field's.
- Android: the stage had a fill of its own inside the card; it is
  transparent like the other fields.
- Android: the key chips in the shortcut editor laid their label out
  from the start of a fixed-width chip; the label is centred.

Gates: Android debug APK on the Nothing Phone; Release xcodebuild on
the iPhone.
2026-08-30 12:03:36 +02:00
enricobuehler cf63f6cf41 feat(console): a shortcut editor for the quick-action ring
The console could point a ring slot at a chord the blob already
carried but could not make one. Design §3.3's pad form, on the
existing widgets: a "Quick-action shortcuts" action row in the Input
tab opens `RingShortcutsScreen` — the blob's chords as a list (name
and legend), and an editor for one: a Name field on the on-screen
keyboard tray (Steam's on a Deck), Ctrl / Alt / Shift / Win as On/Off
rows, the Key stepped with ◀ ▶ through everything `key_vk` knows
(wrapping on A), Save, and Remove for an existing one. A new shortcut
takes the first empty slot, as on the phones. B peels back: keyboard,
editor, screen.

Writes rebase on the store's file and save, like the settings screen —
this is one more whole-file writer. The blob logic (`apply_draft`,
`remove_shortcut`) is pure, and the test drives it directly: the store
is one real file shared by every test in the process, and a round trip
through it races the settings tests (the same race behind the known
`reduce_motion_freezes…` flake).

`every_row_has_exactly_one_tab` learns the row (54 → 55).

Gates: container `pf-console-ui` tests (216), fmt, clippy; the Android
kit build through cargo-ndk, which compiles this crate too.
2026-08-30 11:54:05 +02:00
enricobuehler 7ce448d0c9 feat(console): the ring honours Reduce motion
Design §2.3: with reduce_motion on, the Skia ring crossfades in place — the commit snaps to open instead of springing, and the discs sit at their slots from the first frame and only fade; the twist still opens it. The Android and iOS rings already did this (iOS via accessibilityReduceMotion).

Gate: container pf-console-ui tests (215), fmt, clippy.
2026-08-30 11:44:50 +02:00
enricobuehler 1c34358d21 feat(client/android): the quick-actions editor is the ring itself
Design §3.3 on Android, matching the iPhone: the settings editor shows
the in-stream `RingOverlay` — the same composable — full size over a
backdrop that runs the real two-finger twist (`streamTouchInput` with
a `0` handle: `send_event` drops every wire call, the dial still arms,
commits and cancels). Tap a slot to pick its action from the catalogue
by group with availability notes; drag a disc onto another to swap;
tap the centre to see depth two with inert actions. Whatever closes the
ring it springs back open, so the editor is never left with nothing to
tap. The backdrop is the theme's `surfaceVariant`, one more field.

`RingEditing` on the Compose `RingOverlay`: a slot tap picks instead of
firing, an empty slot is a pick target, the scrim takes nothing, the
8 s idle close is off, and `detectDragGestures` on each disc resolves
the drop to a sector — it consumes past touch slop, which is what keeps
the tap from also firing. `ChordKeycap` and `KeycapDisc` are shared
with the editor's previews.

Shortcuts match the iPhone's editor: a row per shortcut with its disc,
name and legend; tapping it (or Add) opens a screen with the disc and
legend previewed live, a name field, the four modifiers as chips, and
the key on a grouped keyboard of chips (word keys on wider chips).
Remove lives there; a new shortcut takes the first empty slot.

Gate: `:app:compileDebugKotlin`; the debug APK for the Nothing Phone.
2026-08-30 11:41:05 +02:00
enricobuehler a02c574497 feat(client/apple): the ring on tvOS, opened by a short Back
Design §2.5: the Apple TV's entry to the quick-action ring is a short
press of the remote's Back; a long hold still ends the session. The
ring is the same `RingOverlay` the iPhone draws, driven by the pad path
that already existed (`RingState.handleNav`, `Select+A`, the capture's
`ringOpen` gate) — there is no twist, tap or drag on a TV, so those
three paths are `#if os(iOS)` inside the file and the rest compiles
for both.

What carries it:

- `SiriRemotePointer.onShortBack`, fired on a Back released under the
  1 s disconnect hold; the model routes it into `onRingChord`, the
  hook the pad chord already used, so the view wires one closure for
  both. On tvOS that closure toggles: open at the screen centre,
  close if it is up.
- `ContentView` mounts the overlay on tvOS as iOS does (only while
  open), pre-fetches host actions at session start, and shares
  `ringActions`; the twist sink stays iOS-only.
- `TouchInputMode` moves out of `TouchMouse.swift`'s iOS-only block:
  the enum is plain data every platform can name, the gesture machine
  under it stays iOS. On tvOS the Touch mode slot is dimmed with
  "Apple TV has no touch screen".

Not on an Apple TV yet — no device here. Gates: SwiftPM tvOS, iOS and
macOS client targets; a Release xcodebuild of Punktfunk-tvOS for a
generic device (no signing), which is the build that has no DEBUG.
2026-08-30 11:35:09 +02:00
enricobuehler b9838f0024 fix(client/apple): a dragged disc no longer also fires its pick
In the editor the disc's Button fired on release after a drag-to-swap, so the picker sheet opened on top of the swap. One high-priority DragGesture(minimumDistance: 0) owns the disc while editing: a touch that stays within 8 pt is the pick, one carried onto another slot is the swap. In-stream the gesture mask is .subviews, which leaves the Button exactly as it was.

Gate: Release xcodebuild on the iPhone, installed and launched.
2026-08-30 11:28:58 +02:00
enricobuehler e4c8e2d838 fix(client/apple): keycaps in the shortcut editor keep one height
The word keys (Backspace, PrtSc, PgDn) wrapped inside the 44 pt grid
cells, so those caps grew taller than their neighbours. Every cap now
keeps one line at a fixed 30 pt and shrinks its text to fit, and the
groups that hold word keys (editing, navigation, other) lay out on
80 pt columns; letters, numbers and function keys keep the tight 44.

Gate: Release xcodebuild on the iPhone.
2026-08-30 10:31:07 +02:00
enricobuehler 01db9fcdb6 feat(client): readable key legends, keycap discs, a shortcut editor
Four things the quick-actions ring and its editor got wrong on glass:

- Legends. `chord_chip` printed "❖" for Win/Super, "⇧" for Shift, "↵"
  for Enter — symbols that read as nothing to most people. It now
  prints the word a keyboard prints (Ctrl, Alt, Shift, Win, Esc, Enter,
  Backspace, Del, Ins, PgUp, PgDn, PrtSc, Caps), arrows stay arrows,
  and `key_legend` exposes the per-key word. Rust, Kotlin and Swift in
  lockstep, with the contract tests moved to the new text.
- A shortcut on a disc drew that legend as one line, which ran to the
  disc's edge and past it. It is a stacked keycap now: modifiers small
  on top (on Apple the compact glyphs its keyboards print, ⌃ ⌥ ⇧ ⌘),
  the key large under them, shrinking to fit — `ChordKeycap` on iOS,
  the same shape in Compose.
- The three host power actions shared one "power" glyph and were
  indistinguishable on the ring. Sleep is a moon, restart a circular
  arrow, shut down the power symbol, on iOS and Android.
- The iOS shortcut editor was a bare add sheet. Each shortcut is a row
  with its disc, name and legend; tapping it (or Add) opens an editor
  with the disc and legend previewed live, a name field, the four
  modifiers as chips, and the key on a grouped keyboard you tap
  (function, letters, numbers, editing, navigation). Remove lives on
  the same sheet; a new shortcut still takes the first empty slot.

Also `every_row_has_exactly_one_tab` learns the seven ring rows the
T19 commit added (47 → 54): that test had not run since — the
container script tests only `pf-presenter` unless `TEST_EXTRA` names
more crates.

Gates: Release xcodebuild on the iPhone; container `pf-client-core`
(237) and `pf-console-ui` (215) tests, fmt, clippy; Android compile +
`OverlayActionsTest` 6/6; Swift `OverlayActionsTests` updated in step
but not run (needs the macOS core rebuilt).
2026-08-30 10:22:00 +02:00
enricobuehler 4cfc50ee2b fix(client/apple): the quick-actions editor is a sheet, not a push
Leaving the editor on the iPhone dropped the user on the category list
and no category could be entered again. The settings detail column is
deliberately not a NavigationStack (an inner one doubles the title bar
on iPad), so a NavigationLink pushed from it rode the collapsed
NavigationSplitView's own stack and, on pop, took the `List(selection:)`
binding with it. AboutView's Shortcuts and Acknowledgements rows had
met the same thing and are sheets for it; Quick actions follows them:
a plain row that presents the editor in its own NavigationStack with a
Done button.

The backdrop is the Form's cell colour (`secondarySystemGroupedBackground`,
resolved dark under the editor's scheme), so it reads as one more field
rather than a bright stage — the grey was too bright.

Trap: the new `@State` first landed inside `#if DEBUG && !os(tvOS)`, so
the SwiftPM (debug) gate passed while the Release xcodebuild failed.
It sits with the unconditional properties now.

Gates: `pf-touch-apple-check.sh ios|tvos PunktfunkClient` exit 0; the
Release xcodebuild for the iPhone succeeded and is on the device.
2026-08-30 10:07:08 +02:00
enricobuehler 4d613ce033 fix(client/apple): the editor's ring no longer closes on a pick
On the iPhone, after one or two picks the editor went dead: nothing on
the ring answered a tap. UIKit hands a tap on a disc to the backdrop's
`DialCatcherView` as well as to the SwiftUI button above it, so its
tap recognizer ran `ring.close()` on every pick. The ring wound in
behind the picker sheet, and a closed ring's discs are not hit-testable.

The backdrop tap now only dismisses the preview sheet, and whatever
closes the editor's ring — a twist wound back past the commit angle, a
preview row that ends the stream — the ring springs back open once the
wind-in has played (`onChange(of: ring.closing)`), so the editor is
never left with nothing to tap. The caption no longer promises a
twist-to-open state that cannot last.

The backdrop is a plain grey now, as asked, instead of the gradient.

Gate: `pf-touch-apple-check.sh ios PunktfunkClient` exit 0.
2026-08-30 10:02:05 +02:00
enricobuehler 7526e6f2b3 feat(client/apple): the quick-actions editor is the ring itself
Design §3.3, "the editor is the ring": the settings editor now shows the
in-stream `RingOverlay` — the same type, not a copy — full size over a
gradient backdrop, and it answers the real two-finger twist with the
real thresholds. Tap a slot to pick its action from the catalogue by
group (Session, Input, View, Audio, Host, Shortcuts, Empty, each with
its availability note); drag a disc onto another slot to swap the two;
tap the centre to see depth two with inert actions. The shortcuts list
and Reset to default stay under the ring. In a profile that owns its
ring, a line under it says so.

What carries it:

- `DialCatcher` (PunktfunkKit): a UIKit surface running the stream's
  `TouchMouse` with its wire unplugged, so only the dial's arm, commit
  and cancel come out; a one-finger tap reports separately and closes
  the ring (or the sheet) in the editor.
- `RingEditing` on `RingOverlay`: a slot tap picks instead of firing,
  an empty slot becomes a pick target, the scrim passes touches through
  to the backdrop, the 8 s idle close is off, and a `DragGesture` on
  each disc resolves the drop to a sector (released near the centre or
  on its own slot it springs home).
- `HostAction` gains a public init so the editor previews the three
  power slots as offered.

The six-Picker list form from the previous commit is gone; the ring on
appear plays the same spring it does in-stream.

Gates: `pf-touch-apple-check.sh ios|tvos PunktfunkClient` both exit 0.
On the iPhone for feel; drag-to-swap and the twist over the backdrop
are the two things to try.
2026-08-30 09:47:19 +02:00
enricobuehler b1e63bff02 feat(client): a quick-actions editor on all three shells (T19)
The `overlay_actions` blob (design/touch-client-overlay.md §3.2) had
parsers and profile plumbing on every shell but no way to change it.
This is the list form of §3.3: pick what each of the six slots holds,
add or remove custom shortcut chords, and reset to the platform ring.
The ring-as-editor with drag-to-swap and the twist tutorial is still
owed; this is the smallest editor that makes the blob reachable.

iOS: a "Quick actions" row under Touch & pointer, bound through
`scoped(SettingsFields.overlayActions)` like every other profileable
row, with the row's override marker and Reset. The editor
(`QuickActionsEditor.swift`) is six Pickers, a shortcuts list with
swipe-to-delete, an add sheet (label, modifier toggles, key picker,
chord preview) and a Reset to default that drops the override in
profile scope or clears the global. A new shortcut takes the first
empty slot (§3.3).

Android: the same shape as a deep sub-screen (`QuickActionsScreen.kt`,
opened from a Quick actions row in Input with its override badge),
writing through the screen's `update` so the profile diff records the
whole blob. `SettingsGroup` goes internal for it to reuse.

Desktop and the Android console (Skia): six `RingSlot` rows and a
`RingReset` action row in the Input tab. ◀ ▶ step a slot through the
catalogue — empty, the built-ins, the three host power actions, then
the blob's own shortcuts — and write the whole blob back. Shortcut
CREATION is not on the console yet (it needs the on-screen keyboard
flow); a slot can point at a chord the blob already carries. The
console edits the global blob only: it never edits profiles, and D9's
per-row scope picker is new machinery left for later. One test drives
the stepping, the clamp at Empty and the shortcut option.

The slot catalogue is the same list in the same order on all three
shells; host actions are the three advertised power ids.

Gates: `pf-touch-apple-check.sh ios PunktfunkClient` exit 0; Android
`:kit`+`:app` compile through cargo-ndk, JNI floor clean; `cargo fmt`;
`pf-console-ui` tests in the container. Not yet on glass.
2026-08-30 00:39:13 +02:00
enricobuehler 6618d07869 fix(client/android): the sheet's mode row re-reads the live mode
The Resolution and Refresh rows showed the mode the sheet last asked
for, on the belief that Android had no live read-back of the
negotiated mode. It has one: `nativeVideoSize` reads
`NativeClient::mode()`, which the control task overwrites with the
host's `Reconfigured` ack (`pump/control_task.rs`). So a rejected
request left the row lying for the rest of the session.

The request still writes the asked-for mode at once (the ack lands off
the composition), then re-reads `nativeVideoSize` 500 ms later so a
rejection shows through. The Kotlin doc on `nativeVideoSize` no longer
calls the value fixed for the session. No new JNI: the
`nativeCurrentMode` the plan proposed would have duplicated it.

Gate: `:kit:compileDebugKotlin :app:compileDebugKotlin` through
cargo-ndk, JNI floor check clean.
2026-08-30 00:23:40 +02:00
enricobuehler 5adb5ad99e feat(client/apple): the ring's discs spring out one by one
On iOS the six slot discs and the centre now each ride their own
spring from the centre to their ring position when the ring opens:
the centre first, each slot 25 ms behind the last, on a bouncier
settle (response 0.4, damping 0.62) than the whole-ring spring had.
Scale and opacity ride the same spring.

Before, one scalar placed every disc and the discs were inserted with
`if q > 0`, so a disc that first appeared at commit had nothing to
animate from: the twist path sprang, the corner disc and `Select+A`
popped in place. The discs now stay in the tree at 0 while the overlay
is mounted, and a `phase` (twist / open / closing) picks each disc's
target and animation through `.animation(_:value:)`. During the twist
they still follow the finger frame by frame with no animation; the
spring takes over at commit from wherever they are.

Closing: `RingState.close()` on an open ring raises `closing` for
140 ms, so the overlay stays mounted while the discs ease back in
(ease-in 120 ms) and then leaves the view hierarchy as before
(tenet 1). A cancel short of commit still leaves at once. Reduce Motion
keeps the plain 120 ms fade with no spring and no stagger.

tvOS is untouched (the file is `#if os(iOS)`); Android and the Skia
ring are unchanged. Gates: `pf-touch-apple-check.sh ios|tvos
PunktfunkClient` both exit 0. Not yet on glass.
2026-08-30 00:20:20 +02:00
enricobuehler 83cefd3c3f fix(client): a drifting twist no longer locks itself into a scroll
On the Nothing Phone the two-finger twist never opened the ring. Real
fingers never pivot about a fixed point: the centroid drifts a few px
per sample while the pair turns, and the Android scroll path fires a
notch per 4 px of centroid travel. The first notch is final
(scrollEmitted), so the gesture was a scroll before it could turn 10°.
The Rust engine shares the 4 px notch; Swift's is 10 pt against a
16 pt slop, so the iPhone mostly got away with it.

While the pair is undecided — under DIAL_SLOP of travel and under
DIAL_ARM_DEG of turn — no notch goes out, and the scroll anchor follows
the centroid so a real scroll starts smoothly once it crosses the slop.
This is what the design's slop rule meant (§2.1: travel past the slop
before arming means scroll); the engines just ran the notch first.

A new Rust test drives a twist whose centroid drifts ~14 px over five
samples: no Scroll act, the dial arms, and 35° commits. The pan test
that mixed a scroll with a rotation now pans 30 px, past the slop.
2026-08-30 00:10:44 +02:00
enricobuehler 577def855a feat(client): Select+A opens the ring, and the pad drives it
A pad user had no way into the ring: every existing in-stream chord
forwards its buttons (a game sees the X in Select+X), and nothing
in-stream consumed pad input on any client
(design/touch-client-overlay.md §2.6).

`Select+A`, Select first, is the first swallowed chord. All three
routers already hold a lone Select back for the guide hold; an A
inside that window opens the ring and neither press nor release
reaches the host. While the ring is up the pad belongs to it: the
desktop masks the pads and polls the first into menu events, Android
and Apple release held state on the host and adopt the hardware state
silently; on close nothing is replayed. The D-pad and left stick step
the highlight (Right clockwise, Up to 12 o'clock, Down to 6, Y to the
centre), A fires it, B closes, and the sheet becomes a cursor list on
every shell so the same pad reaches the resolution row.
2026-08-29 23:52:51 +02:00
enricobuehler 86c8980b66 fix(console): the Skia ring is desktop-only
pf-console-ui also builds for the Android GL host, where the ring's
host-action cache is configured out; the Android JNI crate then failed
to link the console. The Compose ring serves Android.
2026-08-29 23:36:51 +02:00
enricobuehler 4c5d462dad Merge touch/phase-0 into touch/dial-model 2026-08-29 23:35:49 +02:00
enricobuehler ea92f7148d Merge origin/main into touch/phase-0
main took C ABI 27 for the Steam Controller 2 hidout widening, so
punktfunk_connection_host_caps2 and PUNKTFUNK_HOST_CAP2_TOUCH move to
ABI 28; the header is regenerated. The SC2 passthrough badge and the
touch-fallback badge now stack in the same bottom-centre slot.
2026-08-29 23:33:13 +02:00
enricobuehler 4f1b71edf6 feat(console): the quick-action ring and its sheet in-stream on Skia
The desktop session had no in-stream menu: the ⌃⌥⇧ chords were the
only way to end a stream, cycle stats or mute, and a Deck touch user
had none of them (design/touch-client-overlay.md §0.1).

The ring opens under the fingers from the two-finger twist, frame by
frame, or at the window centre from ⌃⌥⇧O; End stream is a slot behind
a two-press arm. Six translucent discs carry short text labels (the
console has no icon font); the centre opens the sheet — a MenuList
with the full catalogue, where Left/Right on the Resolution and
Refresh rows cycle the presets over request_mode. While the ring is
up it owns the glass: pointer and key events go to it, touch fingers
stop feeding the gesture engine, and it takes the damage key so a
settled ring redraws nothing.

The presenter side is one portable contract in pf-client-core::ring
(RingInput / RingCommand / RingFacts), three default methods on the
Overlay trait, per-frame facts on FrameCtx, and SessionOpts carrying
the profile's overlay_actions. Host-action slots are pre-fetched at
connect and ride the console's own command bus. Touch mode becomes
switchable mid-stream (Capture::set_touch_mode) and a shortcut chord
goes out as VKs down then up.
2026-08-29 23:11:42 +02:00
enricobuehler 67bdadda9b feat(apple): the quick-action ring and its sheet in-stream on iOS
A touch user had one ✕ disc that ended the session and nothing else:
no resolution switch, no host actions, no shortcuts, no way to change
the touch model mid-stream (design/touch-client-overlay.md §0.1).

The ring opens under the fingers from the two-finger twist, frame by
frame, or from the corner disc, which now opens it instead of ending
the session; End stream is a slot behind a two-press arm. Six glass
slots come from the resolved profile's overlay_actions; the centre
opens the sheet with the full catalogue and the resolution presets
over requestMode. It leaves the hierarchy entirely when closed
(tenet 1). The dial reaches SwiftUI through a public DialEvent and a
StreamView.onDial hop; the Keyboard slot summons the soft keyboard by
notification; Touch mode gets a session-scoped override. Haptics:
a tick when the twist arms, a thump at commit, a tap per press, a firm
"no" on a dimmed button, a warning when a destructive slot arms.
2026-08-29 23:00:53 +02:00
enricobuehler 7e073472b8 feat(android): the ring's haptic vocabulary
One thump at commit was the only feedback. The ring now ticks when the
twist arms, taps on every press, thuds firmly on a dimmed button, and
warns when a destructive slot arms — through the console's
ConsoleHaptics, so it follows the system haptics setting.
2026-08-29 23:00:16 +02:00
enricobuehler 8696407897 feat(android): the quick-action ring and its sheet in-stream
A touch user had no in-stream menu: no way to end the stream except an
unconfirmed back gesture, no resolution switch, no host actions, no
shortcuts (design/touch-client-overlay.md §0.1).

The ring opens under the fingers from the two-finger twist, frame by
frame, or at the screen centre from Back — which no longer ends the
session; End stream is a slot behind a two-press arm. Six slots come
from the resolved profile's overlay_actions; the centre opens the
sheet with the full catalogue and the resolution presets over
nativeRequestMode. Toggles leave the ring open, commands close it, a
tap outside or 8 s idle closes it, and it leaves the composition
entirely when closed. Host actions are pre-fetched on the session
tick, never when the ring opens. Touch mode becomes live per session.
2026-08-29 22:52:28 +02:00
enricobuehler f910f88879 feat(core): shortcut key names map to virtual-key codes
A ring shortcut stores key NAMES so one profile fires on every client;
the wire speaks Windows VKs. key_vk (Rust, Kotlin) is the shared
table: modifiers, navigation keys, f1–f24, letters and digits; an
unknown name means the chord does not fire and the editor says so.
2026-08-29 22:52:26 +02:00
enricobuehler 4c493de046 feat(client): a two-finger twist opens the quick-action ring
No gesture engine tracked the angle between two fingers; two-finger
handling was a centroid, and the ring needed an opener no game, host
or OS claims (design/touch-client-overlay.md §2.1).

Each engine now keeps the finger-to-finger vector from the second
finger's landing and compares it on every move: 10° arms the dial and
suppresses scroll, 30° commits (the ring stays open after the lift),
a lift short of commit cancels and sends nothing, a scroll notch
already sent means the gesture is a scroll for its lifetime, a pinch
is nothing. Progress and direction reach the shell as Dial events; the
ring views consume them next.

SDL reports the two fingers of one frame as separate events, so the
desktop judges the twist only when the other finger's position is from
the same frame or older than a pivot's stillness — halfway through a
plain scroll step the vector reads as a 60° turn.
2026-08-29 20:07:41 +02:00
enricobuehler 5f54a1755b feat(core): overlay_actions, the quick-action ring's one setting
The in-stream ring (design/touch-client-overlay.md §3) is configured
by ONE JSON blob, never by a dozen booleans: every cross-client setting
costs about six hand edits per client, and a missed edit ships a
feature on some clients only.

`overlay_actions` is a tier-P field on Settings and SettingsOverlay
(the whole blob overrides, D10), mirrored in Swift and Kotlin with the
existing profile plumbing. The parser is lenient by contract: six
slots, unknown ids and dangling shortcuts become empty slots, a broken
blob is the platform default. The Rust tests are the contract; the
Swift and Kotlin ports carry the same five tests.
2026-08-29 20:07:38 +02:00
enricobuehler 5f5033bc28 feat(core): HOST_CAP2_TOUCH says whether a host injects touch
Selecting the Touch (passthrough) model against a Hyprland, sway or
Omarchy host did nothing: the wlroots injector drops every contact, and
no capability bit let a client know. Windows below build 1809 fails the
same way. The client offered the mode, the host dropped the events, and
no layer said so.

The host now sets HOST_CAP2_TOUCH in the Welcome's second capability
byte when its live injector carries touch (libei, gamescope EIS, KWin;
the PT_TOUCH probe on Windows). The byte never reached a client before:
NativeClient::host_caps2, punktfunk_connection_host_caps2 and
PUNKTFUNK_HOST_CAP2_TOUCH expose it (C ABI 27, additive). Each client
runs the trackpad model for the session when the bit is absent and shows
a one-line notice at stream start.
2026-08-29 19:50:38 +02:00
enricobuehler b96d727447 feat(android): nativeRequestMode switches the live session's mode
The core and the C ABI can renegotiate a session's resolution and
refresh without reconnecting, and the Apple and desktop clients use it.
Android had no binding, so the in-stream Resolution row the touch
overlay design adds could not work there.

One JNI on the nativeDisconnectQuit template, one external fun.
2026-08-29 19:43:44 +02:00
enricobuehler 3e9b6d123e fix(android): touch on the letterbox bars reaches the gesture engine
On a phone held against a 16:9 stream, a trackpad swipe that started on
a black bar did nothing: the gesture layer sat on the aspect-fitted
picture box, so most of the screen was inert and read as unresponsive.

The gesture layer now spans the whole container. Every absolute
mapping — direct pointer, passthrough, the pen lane — measures against
the picture rect (`videoFitRect`, the same centre-aligned fit the
surface is laid out with) and clamps into it, so a contact on a bar
lands on the nearest picture edge. Trackpad deltas need no rect.
2026-08-29 19:43:42 +02:00
enricobuehler 2fe713dc77 Merge pull request 'feat(vdisplay/driver): frame witness + commit-mode flag logging' (#446) from worktree-vdisplay-ddi-sweep into main
Reviewed-on: unom/punktfunk#446
2026-08-29 17:42:06 +00:00
enricobuehler 462a0e30c4 Merge pull request 'feat(install): pick per-distro defaults from user intent' (#439) from luxus/punktfunk:feat/install-intent-defaults into main
Reviewed-on: unom/punktfunk#439
2026-08-29 17:40:42 +00:00
enricobuehler 046fe91691 Merge pull request 'Triton upstream' (#425) from bluelightspecial/punktfunk:triton-upstream into main
Reviewed-on: unom/punktfunk#425
2026-08-29 17:40:04 +00:00
enricobuehler 88213b1bf2 feat(client): long-press to drag on every touch engine
Press-and-hold — the touch idiom for "pick this up" — produced a click
and then a cursor move on all three clients, in trackpad and pointer
mode alike. The engines armed a held left button only from a tap
followed by a second touch within 250 ms. Dragging a window, selecting
text and dragging a file were unreachable from a touchscreen.

One finger held still for 500 ms now presses the left button and drags
until it lifts; the tap-then-drag path stays. A still finger raises no
event, so each engine gets its clock: a run-loop tick on the desktop,
withTimeoutOrNull on Android, a main-queue work item on Apple. The
Android gesture releases a held button on teardown, which it never did.
2026-08-29 19:38:50 +02:00
enricobuehler faa8c92cd0 fix(client): honour invert scroll on two-finger touch scrolling
Turning on "Invert scroll" changed nothing for two-finger scrolling on
an iPad or a Deck. The desktop applied the sign only in the wheel path
(Capture::on_wheel); the touch engine's notches went out raw. On Apple,
InputCapture.sendScroll applies it and its comment called itself the
one scroll sink, but TouchMouse sends straight to the connection.

Both engines now apply the sign where the notch is made, as Android
already did. The Swift comment names the second sink.
2026-08-29 19:34:42 +02:00
enricobuehler ea04667f40 fix(presenter): scroll on exactly two fingers, never three
A three-finger drag scrolled on a Deck and summoned the keyboard on an
iPad. The published vocabulary is two-finger scroll; the desktop engine
scrolled on two OR MORE fingers.

Three or more fingers now anchor their own centroid, disqualify the tap
when they travel, and never scroll or move the cursor — the rule the
Android and Apple engines already apply.

Act::wire() moves to input.rs, the only place that needs InputKind, so
touch.rs has no platform-gated dependency and builds everywhere. Before
this, `cargo test -p pf-presenter` ran zero tests on macOS and passed.
2026-08-29 19:33:36 +02:00
enricobuehler 39a42cf35e fix(install): skip linger where systemd is not PID 1
smoke (arch) got past the sudo fix and died one step later, on the
linger step this branch moved out of --no-start:

  ==> Starting at boot with nobody logged in
    + sudo loginctl enable-linger "$USER"
  System has not been booted with systemd as init system (PID 1).

The smoke containers have no logind, so the call cannot work and linger
would mean nothing there. Reverting the move would put back the summary
that promises start-at-boot and silently drops it under --no-start, so
guard the call instead: run it when systemd is PID 1, warn and carry on
when it is not. An install that otherwise succeeded should not fail on
its last step for a setting the box cannot hold.

--dry-run still prints the command — it reports what a real box would do,
and the defaults matrix asserts on it.

The container path itself is checked by installer-smoke, which is where
it surfaced; no dry-run case can reach it.
2026-08-29 18:55:12 +02:00
enricobuehler 581e25dd4a fix(install): drop the sudo -n rewrite that broke the shim
installer-smoke went red on the root-container leg:

  /tmp/tmp.9gBiJE8x4x/sudo: 2: exec: -n: not found

That shim is install.sh's own — the one it writes when running as root
with no sudo, so the verbatim `sudo …` lines from platforms.json still
work — and it is `exec "$@"`. Rewriting `sudo install -d …` to
`sudo -n install -d …` makes it `exec -n install -d …`, which is not a
command. The smoke job covers that path on purpose.

Not introduced by the previous commit: driving the run() from edf4effb
with no TTY and --yes against the same shim fails identically. Narrowing
the condition moved which sessions hit it, not whether they do.

So the rewrite goes. It buys nothing it claimed: with no terminal, sudo
already exits at once with "no tty present and no askpass program
specified" rather than hanging, and where an askpass helper IS set up,
-n breaks the one unattended path that works. Deleting it also drops a
global sed over an arbitrary command string.

The gate grows the case that would have caught this. Its stub sudo is
now that shim byte for byte, and it asserts the command actually runs.
First attempt at that check was vacuous — run() echoes each command
before running it, so a marker word matched the echo and passed on known
-bad code. The probe is `sudo expr 40 + 2` and the assertion is on 42,
which appears nowhere in the command text.
2026-08-29 18:48:58 +02:00
enricobuehler 5d2f6f0e32 feat(vdisplay/driver): frame witness + commit-mode flag logging
The correctness sweep found no IddCx 1.10 divergence that could starve
frame generation, so the driver now testifies instead: every acquire
that ends >=1 s of silence (or lands >=250 ms after its display time)
logs the OS's PresentationFrameNumber step and PresentDisplayQPCTime
lag, splitting a FRAME-GENERATION hole into dropped / delayed /
never-composed from inside the swap-chain. The commit-modes callbacks
log each path's ACTIVE/CHANGED flags so an OS-side head deactivation
is visible next to a hole.

On .173 the witness immediately showed the loopback rig's cursor
wiggle generates no damage (ssh runs in session 0; only the console
session is composed): an idle-desktop hole reads pfn +1/fresh, and
with real session-1 damage the head composes hole-free.
2026-08-29 18:21:55 +02:00
enricobuehler 9bc6c606fb fix(install): sudo -n, --no-start linger, and couch-box scope
Review of the intent-based defaults. Five fixes, three of them defects
the new matrix could not see, so each one gains a case that fails on the
code as it stood.

run(): the `sudo -n` rewrite keyed off --yes, but no terminal already
implies --yes and never the reverse, so `sh install.sh --yes` typed at a
prompt sent `sudo -n` and died on the first step without a cached ticket.
sudo reads /dev/tty, not stdin, so a terminal under --yes prompts fine.
Gated on `[ -z "$TTY" ]` instead, which is the case that actually has
nobody to type a password.

linger: the summary promised "start at boot" unconditionally, but the
`loginctl enable-linger` sat inside `if [ "$START" = 1 ]`, so
`--no-start` printed the promise and dropped it. Every case in the matrix
passes --no-start, so the gate was certifying exactly that. Linger is
configuration, not starting — moved out of the START branch, still ahead
of the `systemctl --user` probe it has to precede.

couch_box(): keying off FAMILY=sysext and a `ujust` on PATH covered
Silverblue, Kinoite, Bluefin and Aurora — desktop workstations that ship
rpm-ostree and ujust and got the group plus linger under --yes without
being asked. Narrowed to `like bazzite || like nobara`, which is what
install.mdx already claimed; gamescope.md corrected to match.

The group prompts name the grant again (usbip attach, only on a machine
you trust, with the docs link). Asking about intent is right, but under
--yes the summary is the only place it is stated, so that line names the
group too.

sunshine_active(): any non-zero exit from `detect-conflicts` read as
"Sunshine is running", so a crashed or too-old host binary would flip
GameStream on and open the plain-HTTP pairing port unasked. Only exit 1
is an answer now; anything else falls through to the unit probe.

check-install-defaults.sh also lifts run() out of the script and drives
it against a stub sudo — --dry-run returns before run() executes
anything, so no dry-run case can reach it.
2026-08-29 18:12:27 +02:00
enricobuehler abde5ae2d8 fix(client/apple): an unmapped SC2 output id says so once
Gating the sweep left the unknown-firmware case silent: the only log line
lived inside it, so a firmware that does not put report `id` at
100F6C<id+0x35> would drop every write for that actuator with nothing in the
bundle to say why.

Warn once per id instead, naming the id and the UUID that was missing.
Together with the connect-time GATT census that is enough to add the pad's
ids to `Sc2Device.outputCharUUID` from one user's logs — which is the only
reliable fix, since ten output characteristics share one property mask and
nothing on the device distinguishes rumble from a trackpad pulse.
2026-08-29 18:11:16 +02:00
enricobuehler 1f5444f947 fix(client/apple): the SC2 output sweep stops writing blind
The per-report characteristic fallback rotated Steam's output payloads
across every writable characteristic of the Valve service, including
100F6C34 — which parses what it receives as a settings command (0x87 writes
a firmware register). A swept output frame could therefore persist an
arbitrary setting on the user's own controller. Known-purpose
characteristics are now excluded from the rotation.

It also never converged: nothing observes whether a write produced haptics,
so there is no success signal to latch on. With N candidates the correct one
is live one second in every N, for the life of the session — so on an
unknown firmware it delivered broken haptics AND continuous misdirected
writes, rather than degrading. Gated behind `verbose`, which is what it is:
the bench tool that mapped the firmware in the first place.
2026-08-29 18:06:53 +02:00
enricobuehler dc8ae1b598 fix(client/apple): the SC2 caption and its default say why
The caption ran 23 words across two clauses of what the setting does and
none of what it costs; the house rule is one of each, ~12 words for a
toggle. Its opening clause is now Android's word-for-word, with the
Apple-only cost — the Bluetooth prompt — as the rider, and the row label
matches Android's too.

The storage comment said the default mirrored Android's. Android defaults
ON. Record the prompt as the reason they differ, so the next reader does not
"fix" the mismatch.
2026-08-29 17:50:38 +02:00
enricobuehler 88b69ccfa9 docs(sc2): Windows needs no udev rule; sc2_capture gets a row
"Steam's controller list stays empty" offered a Linux udev rule as the only
answer. Windows has no udev and no permission gate — the pad is a UMDF
device — so the same symptom there means a missing or stale driver package,
and the page sent readers to a fix that cannot apply.

`sc2_capture` was also undocumented, including why it defaults on for
Android and off for Apple.
2026-08-29 17:50:36 +02:00
enricobuehler 1b4a11d401 docs(abi): the v27 hidout widening and the raw-report uplink
The embedder guide still described the HID-output pull as DualSense-only and
had no entry for `punktfunk_connection_send_hid_report`, so an out-of-tree
embedder reading it would not learn that `PunktfunkHidOutput` grew 19 to 85
bytes — the one change that can corrupt its memory if it skips the startup
version check.

The guide is the living document; CHANGELOG.md stays a per-release file and
picks the ABI row up at the next cut.
2026-08-29 17:50:34 +02:00
enricobuehler 44d40808f9 fix(sc2): the paired output-length tables are hand-mirrored
`Sc2Device.strippedOutputLen` said its parity with the host's
`triton::out_report_len` meant "a drift on either side fails CI". It does
not: the Swift test transcribes the host numbers into a literal, so it
catches an edit made in Swift and is blind to one made in Rust — which would
leave the client trimming its GATT writes to a stale length.

Say what the guard actually covers, on both sides, so whoever edits either
table knows it owes the other.
2026-08-29 17:50:19 +02:00
enricobuehler 8e0a29a532 style(host): rustfmt the devtest --triton flag
Two statements landed indented 8 spaces inside a 4-space block, so
`cargo fmt --all --check` failed. The fmt gate is repo-wide: one dirty file
blocks every commit, not only this one.
2026-08-29 17:50:17 +02:00
enricobuehler 2bc479c9b5 fix(client/apple): the SC2 toggle's storage reaches the macOS build
`sc2Capture` was declared inside the `#if os(iOS)` block, but the settings
row that binds `$sc2Capture` is gated `#if os(iOS) || os(macOS)`. The macOS
build therefore failed with "cannot find '$sc2Capture' in scope", taking the
whole PunktfunkClient target — and `swift test` with it — down.

Moved past the `#endif`, next to the other cross-platform storage. The
comment claimed the storage was already unconditional; its iOS-only
neighbours are why that read as true.
2026-08-29 17:50:06 +02:00
enricobuehler c3bd34112d Merge pull request 'A standby TV stops costing the stream its frames, half the time' (#445) from worktree-standby-sink-default into main
Reviewed-on: unom/punktfunk#445
2026-08-29 15:42:15 +00:00
enricobuehler 7d61c96889 feat(vdisplay/windows): standby sinks are neutralised by default
A connected-but-inactive external sink - the standby TV that is part of
no topology - keeps probing its link, and the OS then drops composed
frames for the virtual head while other processes present normally. The
sweep that disables its devnode for the session already existed, but sat
behind the experimental pnp_disable_monitors flag nobody enables.

Measured on the .173 lab box (standby LG TV on HDMI, IDD-push loopback
under continuous cursor damage), 16 alternating pairs of 150 s legs,
each leg asserting the treatment actually applied via the sweep's own
"PnP-disable: monitor devnode disabled" line: the baseline produced a
hole-free leg 0 times in 16, the sweep produced one 8 times in 16
(Fisher p~0.002); median hole 6.3 s -> 0.7 s.

It is an improvement, not a cure, and the doc says so: 6 of the 16
treated legs still took FRAME-GENERATION holes tens of seconds long, and
the hole-time rank-sum is only borderline (p~0.055). The residual holes
are the same class, so the root mechanism stays open.

The operator's own displays are NOT in scope: only this selector is
limited to external physicals in no topology at all, so an internal
laptop panel can never be picked. Disabling displays the operator was
actually using stays opt-in under pnp_disable_monitors.
2026-08-29 17:27:17 +02:00
enricobuehler 2f3af9b837 feat(vdisplay/driver): report frame statistics to the OS (case-#4 A/B)
The drain loop now calls IddCxSwapChainReportFrameStatistics after every
FinishedProcessingFrame: COMPLETED status, the metadata's present number,
acquire/publish QPC brackets, and the surface's pixel/byte magnitudes.
The MS sample leaves this call as a TODO and this driver never made it;
the statistics feed the OS's per-output scheduling, and case #4's shape
is DWM starving exactly this head while composing a lit sibling at full
rate. Best-effort (first rc logged once, never breaks the drain), with
setx /M PFVD_FRAME_STATS 0 as the A/B escape hatch.
2026-08-29 17:27:01 +02:00
enricobuehler 1495ec2bd2 Merge pull request 'fix(host/input): serve Switch Pro SPI reads by range, not exact pairs' (#444) from worktree-switchpro-spi-range into main
Reviewed-on: unom/punktfunk#444
2026-08-29 15:18:11 +00:00
enricobuehler 0f20e72d03 fix(host/input): serve Switch Pro SPI reads by range, not exact pairs
Both sticks on a virtual Switch Pro sat in the top-right corner under
Steam, reaching centre only at full down-left. Buttons and motion were
fine, and the kernel's own evdev node read correctly throughout.

The SPI-flash handler matched an exact (address, length) pair, and those
pairs came from hid-nintendo. Steam does not use the kernel driver here;
it uses SDL's HIDAPI Switch driver, which reads the same calibration as
18 bytes at 0x603D and 22 at 0x8010. Neither matched, so both fell to
the zero-fill path — which echoes the requested address, so SDL accepted
the reply and parsed a zeroed calibration. A zero centre is never
subtracted, leaving every raw axis value positive.

Serve reads by range from a modelled flash image, so every consumer gets
the same bytes whatever shape it asks for. What hid-nintendo receives is
byte-identical, and the IMU block already used SDL's length, so motion is
untouched. The caller loses its zero-fill fallback.
2026-08-29 16:56:17 +02:00
enricobuehler 3725db7cfe Merge pull request 'fix(presenter): F11 to fullscreen no longer quits the Windows session' (#443) from worktree-win-f11-swapchain into main
Reviewed-on: unom/punktfunk#443
2026-08-29 14:38:51 +00:00
enricobuehler 09662c0466 fix(presenter): a refused swapchain costs fullscreen, not the session
A `recreate_swapchain` failure in the resize handler propagated out of
`run_session`, ending the stream; the shell then reported a live
session as "Couldn't connect".

A driver that refuses the fullscreen-sized swapchain now costs the
fullscreen only: warn, drop back to windowed, and rebuild against the
geometry that was already working. A windowed failure still
propagates, since there is no smaller state to fall back to.
2026-08-29 16:23:48 +02:00
enricobuehler 8f8f50fb79 fix(presenter): drain present-wait before reusing the old swapchain
Toggling fullscreen mid-stream on Windows 11 quit the session with
`vkCreateSwapchainKHR: VK_ERROR_UNKNOWN`.

`recreate_swapchain` drained the present-wait waiter AFTER calling
`vkCreateSwapchainKHR(oldSwapchain = old)`, so the pf-present-wait
thread could sit inside `vkWaitForPresentKHR(old)` while the driver
retired that same swapchain. `vkCreateSwapchainKHR` externally-
synchronises `oldSwapchain`, so the overlap is a spec violation.

The drain (and the unclaimed-present drop) now run before the create.
`present_timing.rs` stated only the destroy half of the rule, which is
how the drain came to sit on the wrong side; it now states both.

The failure text also carries extent, format, colour space, present
mode and image count -- the first questions a field report raises.
2026-08-29 16:23:48 +02:00
enricobuehler 21d30b7320 Merge pull request 'fix(apple): relink stage-4 when the display link stops vending' (#442) from worktree-stage4-link-watchdog into main
Reviewed-on: unom/punktfunk#442
2026-08-29 08:48:24 +00:00
enricobuehler d58ed540de Merge pull request 'docs(changelog): tighten v0.33.0 bullets to house style' (#441) from luxus/punktfunk:docs/changelog-v0.33-tighten into main
Reviewed-on: unom/punktfunk#441
2026-08-29 08:47:50 +00:00
enricobuehler 87bc0883db fix(apple): relink stage-4 when the display link stops vending
Field 2026-08-28, iPad Pro on iOS 27: the picture froze twice in one
session while audio and input kept running, and only reconnecting
cleared it. pf-present logged decode at full rate against ok=0,
noDrawable=120 and no vendLeadMs samples at all, so the
CAMetalDisplayLink had stopped calling back.

Stage-4 has no drawable source of its own: every drawable arrives as
update.drawable. A link that goes quiet is therefore a stream that
never presents again, and nothing noticed. Stage-3's
PresentGate.staleAfter insurance does not reach this path, and the
link is only invalidated at session teardown.

The render thread now tracks how long it has gone without a vend and,
past 250 ms with frames still decoding, retires the link generation
and starts a fresh one. Invalidating the old link also returns the
drawables it holds, so this covers an exhausted pool as well as a
dead link. The relink is logged to the send-logs ring.

Also holds the link delegate for the link thread's lifetime. It is
stored weakly and was kept alive only by a local whose last use is
that store, which ARC is free to release on the spot.
2026-08-29 09:54:39 +02:00
luxus b58606dbfc docs(changelog): tighten v0.33.0 bullets to house style
v0.33.0 adopted Keep a Changelog headings but kept PR-body
prose. Replace that section with two-sentence bullets and
add the missing bullet-shape rule to docs/writing.md.
2026-08-29 01:13:55 +02:00
enricobuehler 35f549f04d Merge pull request 'chore(release): cut 0.33.0' (#440) from worktree-release-0330-prep into main
Reviewed-on: unom/punktfunk#440
2026-08-28 22:43:42 +00:00
luxus edf4effb1e feat(install): pick per-distro defaults from user intent
The guided installer defaulted every option to no, so Bazzite and
Nobara users who held Enter got a degraded pad and no linger —
the opposite of those distro pages. Prompts now name the intent,
defaults follow the box, and a summary prints before the first sudo.

Fixes #431
2026-08-29 00:25:40 +02:00
enricobuehler a6efc467ce chore(release): cut 0.33.0
45 commits since v0.32.0 (29 non-merge). Nothing versioned moves: wire
stays 2, C ABI 26, driver 6, gamepad channel 3, plugin index 1, host
event schema 1, gamescope pfhdr8. The management API grows two routes
for host actions, so openapi steps 0.32.0 -> 0.33.0 with a real diff
rather than a stamp.

Minor because of what the tree gained, not what it renumbered: host
power actions on every client, punktfunk-host ctl, Omarchy as a
supported host, and a widened GRANT_ALL. That last one is the embedder
trap of this cut -- 0x3F -> 0x7F is a value change to an existing macro,
so equality comparisons against it now read a pre-power record as
Custom. GRANT_ALL_PRE_POWER exists for that.

First CHANGELOG section written to docs/writing.md section 2: Keep a
Changelog categories, 224 lines against v0.32.0's 454.

Also drops chacha20 0.10.1, which upstream yanked after v0.32.0 shipped.
It is transitive via chacha20poly1305 and 0.10.2 is a lock-only bump, so
cargo audit is back to its two allowed unmaintained warnings.

Gates on the Mac: cargo fmt --check, cargo test -p punktfunk-core --lib
--features quic (508 passed, 0 failed), the C ABI harness (abi_version=26),
check-docs-drift.sh, check-docs-links.sh, cargo audit, both openapi copies
byte-identical, Play notes 424/500 chars and unique, and the ISO 24495
auditor clean on both notes files.
2026-08-29 00:17:09 +02:00
enricobuehler 95a2683aae Merge pull request 'A theme switch keeps the stream's resolution, and the console wears the whole theme' (#437) from worktree-omarchy-theme-live into main
Reviewed-on: unom/punktfunk#437
2026-08-28 22:00:11 +00:00
enricobuehler 8fcd9354b5 Merge pull request 'The host can be put back to sleep from the couch that woke it (host actions)' (#436) from worktree-host-actions into main
Reviewed-on: unom/punktfunk#436
2026-08-28 21:59:16 +00:00
enricobuehler 81c103c54a Merge pull request 'docs: adopt the Punktfunk house style' (#438) from worktree-house-style into main
Reviewed-on: unom/punktfunk#438
2026-08-28 21:58:14 +00:00
enricobuehler 40fc741ad5 chore(ci): the three new ABI constants join the undocumented-env baseline
The docs-drift ratchet matches PUNKTFUNK_* by token spelling, not by env
read, so the cbindgen #defines this branch adds — GRANT_POWER,
GRANT_ALL_PRE_POWER and HOST_POWER_CLOSE_CODE — read as new undocumented
knobs. They are ABI constants, not knobs: every sibling grant bit and
close code is already in this file for the same reason.
2026-08-28 23:50:44 +02:00
enricobuehler ad6b5184dc docs: adopt the Punktfunk house style
Three writing surfaces were being asked to hold design reviews. Commit
subjects on main averaged 101 characters, CHANGELOG.md ran to 6,090
lines for eight versions, and module rustdoc carried milestone codes and
soak durations that git blame already keeps honest.

The rules now live in docs/writing.md: Conventional Commits with a
72-character cap, Keep a Changelog categories for new sections, and
comments that state an invariant rather than recap a diff. CONTRIBUTING,
AGENTS.md and the Gitea PR template point at it, and the CHANGELOG
header says which form new sections take.

Nothing existing is rewritten. The changelog archive, the release notes
and every current rustdoc header stay as they are; the style applies to
what gets written next.

Source: github.com/luxus/punktfunk-house-style
2026-08-28 23:48:25 +02:00
enricobuehler e12f3a62de Merge pull request 'The console waits for the host instead of failing its first enable' (#435) from worktree-web-console-start-race into main
Reviewed-on: unom/punktfunk#435
2026-08-28 21:46:29 +00:00
enricobuehler 60d3e89c07 Merge pull request '--uninstall undoes what punktfunk-omarchy setup did, and the detection matrix grows an Omarchy row' (#434) from worktree-omarchy-installer-gaps into main
Reviewed-on: unom/punktfunk#434
2026-08-28 21:45:54 +00:00
enricobuehler 55b1dc848d A theme switch keeps the stream's resolution, and the console wears the whole theme
Two findings from an Omarchy field session, one root cause each.

## The output resolution reset on every theme change

`omarchy-theme-set` ends in `omarchy-restart-hyprctl`, which is `hyprctl reload`
— and a reload drops EVERY runtime `hyprctl keyword`, our monitor rule for the
streamed head included. This crate already documented that fact (it is what
`restore_heads` relies on to re-light an `exclusive` session's monitors); nothing
put the rule back. So the head silently fell to its default mode mid-stream.
Resizing the client window appeared to fix it only because a resize on Linux
re-creates the output, which runs `set_monitor_rule` again.

The Hyprland backend now subscribes to the compositor's event socket and
re-applies the rule on `configreloaded`. Any reload gets it — the operator's own
`hyprctl reload`, `omarchy-refresh-config`, a theme switch — not just the one
that was reported. The watcher is a socket read, not a poll: an idle session
costs nothing, and its guard shuts the socket down on teardown so the thread
cannot outlive the output (sessions are minted on every mid-stream resize).

The mode only. A reload also undoes `topology: exclusive`'s head disables, but
re-disabling them from this watcher risks a permanently dark desk — teardown's
own `restore_heads` runs a reload to re-light them, and nothing orders the two
today. Noted in the code where the fix belongs.

## The console did not follow the theme, and only half wore it

Two separate causes:

* `useUiConfig` was `staleTime: Infinity`. The ports on that payload genuinely
  cannot change without a restart, but the theme can, so the console sat in the
  old palette until someone reloaded it. Now polled at 2 s (and refetched on
  focus). Polled and not pushed because the console's SSE stream is a proxy of
  the HOST's, and the host reads nothing about themes.
* Only `accent` and `mode` were carried. The template has rendered `background`
  and `foreground` all along and they were dropped on the floor, which is why a
  themed button sat on the console's own violet chrome.

All three colours now reach the page, and a `[data-omarchy]` block in styles.css
expands them into every surface with `color-mix(in oklab, …)` — cards, hovers,
borders, the accent surface — plus the three tints of the lens mark, whose fills
were hard-coded violet and now follow the brand tokens. One block covers light
and dark: `:root[data-omarchy]` outranks both `:root` and `.dark`, and the mixes
take their direction from the theme's own background/foreground pair.

`--success` / `--warning` / `--destructive` deliberately do not follow: they
encode meaning, and a theme whose accent is red must not leave "delete" and
"save" the same colour.

Every ratio was measured, not chosen: `web/tools/check-omarchy-palette.mjs`
reads them back out of the stylesheet, redoes the mixes and asserts WCAG
contrast across six shipped themes (Tokyo Night, Gruvbox, Nord, Catppuccin
Latte, Rose Pine Dawn, Everforest Light). It runs in `postbuild` beside
check-i18n, because nothing else in the repo can catch an unreadable palette —
the mixes resolve in the browser, so the typecheck, the tests and the build all
pass on one. Everforest Light is in that table on purpose: its own foreground is
5.2:1 against its own background, which is what caps a derived muted colour at
3.8:1 there. That ceiling is recorded in the stylesheet.

## Verification

* pf-vdisplay: 273 tests pass on linux-gnu (docker), `xcheck.sh linux clippy`
  clean. The new `is_config_reload` test proved non-vacuous by relaxing the
  matcher to a `contains` and watching it fail.
* web: build + `tsc --noEmit` + 37 tests + biome all clean. The theme-file
  validator test proved non-vacuous by dropping the background check; the
  palette gate proved non-vacuous by re-tuning a ratio.
* On glass: the trigger chain confirmed on the Omarchy box (`omarchy-theme-set`
  → `omarchy-restart-hyprctl` → `hyprctl reload`), `configreloaded` present in
  the Hyprland 0.56.2 binary, and the event socket found and connectable at
  exactly the path the new code computes. The built console served the box's
  live theme end to end over `/_auth/ui-config`.
* NOT yet verified on glass: the re-apply itself. A stream was live on the box
  and a `hyprctl reload` would have disrupted it.
2026-08-28 23:38:08 +02:00
enricobuehler 62728317cf The couch gets the other half of the round trip: host power on every client
design/host-actions.md P2. The host has offered sleep/restart/shutdown since
the previous commit; this is where a person can reach them. Every client's host
menu grows the rows right where Wake host appears when the machine is asleep —
finish on the TV, sleep the host from the same menu that woke it.

One shared core: pf_client_core::host_actions holds the discovery read, the
id-only invoke, and a process-wide TTL cache the console, the GTK page and the
Windows tile all read, so three shells cannot end up disagreeing about what a
host offers. Every shell warms it on a refresh tick it already had.

Discovery is PRE-fetched, never fetched when a menu opens: the console screen
holds a cloned row, and rows that appear under a cursor already moving are a
hazard when two of them shut a machine down. For the same reason the console's
armed state became WHICH row is armed rather than a bare flag — a flag let an
arming press on Forget fire Shut down host. Restart and shut down confirm;
sleep is reversible from the same menu, so it goes on one press. An action the
host says it cannot run right now stays listed, disabled, and says why.

Surfaces: console-UI rows + ConsoleCmd::HostAction with session and Android
dispatch; GTK card menu (plus a card-level Toast) with an AlertDialog confirm;
Windows tile menu, its label built and matched through one function so a menu
whose rows outlived their handlers cannot run a different verb; Android touch
home and Skia console; Apple host card and gamepad options. Unknown ids render
from the host's own title on all of them — a later host action needs no client
release to appear.

Typed close: the Swift hostPower case and the Kotlin host-power token. Without
them, sleeping your own host from the couch reads as a crash on those two
clients.
2026-08-28 23:26:26 +02:00
enricobuehler 51f64382ec --uninstall undoes what punktfunk-omarchy setup did, and the detection matrix grows an Omarchy row
PR #428 taught scripts/install.sh about Omarchy: detection, the `-Sy` then `-S` split that gets past
the libalpm guard, and the hand-off to `punktfunk-omarchy setup`. It wired the INSTALL path only.

`--uninstall` removed the packages but left everything `punktfunk-omarchy setup` had put OUTSIDE
them: tagged ufw rules, the user-unit drop-in, the app-menu entry, hooks.json, and the
`custom_picker_binary` takeover in xdph.conf — the last of which keeps pointing a live compositor at
a picker shim that is no longer installed. `punktfunk-omarchy remove` is the reverse and is
idempotent, but it ships IN the host package, so it has to run before pacman takes it away. Its
guard carries the `|| [ "$DRY" = 1 ]` that step 1b already uses, so the dry-run matrix can see it on
a machine that has never had the package.

Gate 7's matrix had no Omarchy row at all, which is why this went unnoticed; its own comment says a
fix to the installer adds its case there. Three added — the split install, the setup hand-off, and
the uninstall — each proved non-vacuous by reverting the code it covers and watching the gate go red.

The first of those three now covers a fix that reached main separately: `run()`'s non-interactive
rewrite matched only `^sudo pacman -Syu `, so `--yes` and every terminal-less `curl | sh` asked
"Proceed with installation?" with stdin on /dev/null and died. The `-S ` rule that fixes it landed in
the --channel switch work while this branch was open. The row keeps it from regressing.
2026-08-28 23:02:57 +02:00
enricobuehler 14ec3d1b5d Merge pull request 'The one-command install stops hiding on the hub page' (#433) from worktree-docs-universal-installer into main
Reviewed-on: unom/punktfunk#433
2026-08-28 21:00:25 +00:00
enricobuehler 6ca7e4a7ec docs: the one-command install stops hiding on the hub page
The guided installer covers apt, dnf, pacman and sysext boxes, but only the
install hub mentioned it — a reader who lands on /docs/ubuntu from a search
never learns the whole page has a one-liner. Put the same block above step 1
on every page the script actually detects, and leave NixOS, SteamOS and
Windows alone: the script refuses those and points at their pages.

Also list Omarchy on the hub cards and in the quickstart table. It was added
to the sidebar with #428 but never to the two pages that route people there.
2026-08-28 22:47:21 +02:00
enricobuehler 9620c71b1f The console waits for the host instead of failing its first enable
On a fresh install the web console's FIRST start always failed at the systemd
level:

    punktfunk-web.service: Failed to load environment files: No such file or directory
    punktfunk-web.service: Failed to spawn 'start' task: No such file or directory
    Failed with result 'resources'.

`punktfunk-web.service` declares `After=punktfunk-host.service`, but that was
never a readiness gate: the host is `Type=simple`, so systemd considers it
started the instant it is SPAWNED — seconds before it writes anything. The
console's mandatory `EnvironmentFile=%h/.config/punktfunk/mgmt-token` then read
a file that did not exist yet. `StartLimitIntervalSec=0` meant the restart two
seconds later succeeded, so the console did come up — but the first enable
printed a hard failure on a perfectly good install, and every caller that
watches the exit status believed it.

Field report 2026-08-28 (Omarchy): `punktfunk-omarchy setup` enables the host
and the console back to back, so it lost that race every single time and
reported "Failed to start punktfunk management web console" on an install where
pacman, the repo and all three packages were fine.

The gate now lives in punktfunk-web-init.service, which the console already
orders after (`After=` + `Wants=`) and which is `Type=oneshot` — so blocking
there is the readiness gate the ordering already claimed to be. No new unit, no
new directive, no change to the host's startup path.

It waits for the console's OWN precondition rather than a proxy for it, which
matters because the two files it needs are written far apart: `mgmt-token` goes
out early in `serve` (main.rs, before the listeners) while the identity cert
comes LAST, inside `mgmt::run` -> `identity::load_or_adopt`. Waiting on the
token alone would only have moved the failure to the cert. The check mirrors
web/nitro-entry/tls-paths.mjs exactly: a token, plus a non-empty cert/key pair
from one directory — native, or legacy for a host that never took the identity
split.

Also drops `ConditionPathExists=!%h/.config/punktfunk/web-password` from
web-init. That skipped the unit from the second boot onward, which is precisely
when the wait must still run, and it tied a host-readiness gate to the presence
of an unrelated password file. web-init.sh is idempotent instead, and in steady
state (every start after the host's first run) it returns without sleeping.

Timing out is not fatal — it exits 0 so the console still starts and the
existing Restart backstop takes over, but says WHY, where the bare systemd error
it replaces named a missing file and never the host that owed it.

Mirrored into the NixOS module, and module-check.nix now asserts the absence of
the path condition it used to assert the presence of. All four Linux packages
(deb, RPM, Arch, and Bazzite via the RPM) ship the same two files, so the fix
reaches every one of them from here.

check-docs-drift.sh gate 8 runs the real script against a faked config dir:
it waits when nothing is there, still waits when only the token is there (the
leg a token-only fix would have missed), returns instantly for both the native
and the legacy identity, and stops waiting as soon as the files land. Each case
proved non-vacuous by reverting the fix and watching it go red.
2026-08-28 22:35:52 +02:00
enricobuehler 4e392a7666 Merge pull request 'The installer can move a host between channels, not just onto one' (#432) from worktree-installer-channel-switch into main
Reviewed-on: unom/punktfunk#432
2026-08-28 20:18:59 +00:00
enricobuehler 37902ebac8 Merge pull request 'The retry loop stops eating the restore that re-lights the desk' (#430) from worktree-hyprland-exclusive-restore-strand into main
Reviewed-on: unom/punktfunk#430
2026-08-28 20:18:13 +00:00
enricobuehler 2e4561d572 The installer can move a host between channels, not just onto one
`--channel canary` already worked, but only on a box with nothing installed:
the guided installer skips its whole install block once the three binaries are
on PATH, so re-running it with `--channel` on an existing host printed
"already installed" and changed nothing at all. Switching was a docs page of
`sed` one-liners you ran by hand.

Now `--channel` means the same thing on a fresh box and an installed one, and
it means it in both directions. A switch is a repo rewrite plus a re-resolve
that is allowed to go DOWN — canary is always a minor ahead of stable by
construction, so canary→stable is a downgrade and every package manager
refuses one unless told: apt gets explicit version pins from `madison`,
pacman `-Sy` then `-S` (never `-Syu`, which looks at the lower stable version
and does nothing), dnf a `distro-sync` behind the `install`, and the sysext
script already moved both ways on its own per-feed rollback floor.

Three things that were quietly wrong fall out of doing this properly:

- pacman appended `[punktfunk-canary]` while leaving `[punktfunk]` in place,
  so a canary install left both repos enabled and let repo order decide which
  build you got. The switch drops whichever section is there first, reusing
  --uninstall's own delete.
- `--channel` defaults to stable, so lifting the early-exit would have let a
  bare re-run — to fix a group, to open a firewall port — silently drag a
  canary box back a channel. With no explicit `--channel` the script now
  follows whatever the box is on. The gate below is built around this case.
- A switch moves every punktfunk package on the box, not the three the script
  installs, or a hand-installed punktfunk-gamescope is stranded on the channel
  the machine just left. That set comes from --uninstall's existing query.

Verification, since a downgrade path touches packages people already have:
check-docs-drift gains gate 8, six --dry-run cases over the four families with
the repo config and the installed binaries faked (`PUNKTFUNK_INSTALL_ETC`
joins `PUNKTFUNK_INSTALL_OS_RELEASE` as a testing override). The case that
matters is a canary box missing a package and re-run bare — mutating the
follow-the-box rule turns it red, which is how it was checked. installer-smoke
adds the round trip against the real registries, where the version has to move
and then come back; the textual gate proves the commands are emitted, only
that one proves the downgrade happens.
2026-08-28 22:08:23 +02:00
enricobuehler 47dbd7b514 The host learns to end its own day: sleep, restart and shutdown as gated actions
design/host-actions.md P0 (host core) + P1 (web console). A reusable action
registry — GET /api/v1/actions (per-caller permission + honest availability) and
an id-only POST /api/v1/actions/{id} on the mgmt cert lane — with three power
built-ins, so the Wake-on-LAN round trip finally closes from the couch.

Core: GRANT_POWER (1<<6) joins the mask; GRANT_ALL 0x3F -> 0x7F; the legacy-full
read rule (an explicitly stored pre-power 0x3F reads as the current GRANT_ALL,
so old Full records neither render Custom nor silently lack Power); typed close
RejectReason::HostPower (0x6B) so ended sessions say why.

Host: power executors (logind via zbus behind the already-shipped polkit rule,
deliberately without -ignore-inhibit; Windows SeShutdownPrivilege +
InitiateSystemShutdownExW / SetSuspendState; macOS answers 501); ordering
reply-202 -> typed close -> 1 s grace -> act; single-flight; another device's
live session blocks a cert-lane invoke (409); action.invoked on the event bus;
denials logged once per (fingerprint, action) per boot. The plugin token gets
neither route.

Console: Host-power card (password-confirmed, the update-apply recipe) + a BFF
route that keeps the invoke off the ungated catch-all; Host power toggle in the
Access sheet. Mirrors: web/Kotlin/Swift consts + legacy rule, pf-client-core
preset_label now masks (an old client no longer renders a new host's Full as
Custom). Tray's service-restart row renamed Restart Punktfunk before the
clients ship a machine-level Restart host. Docs: host-power page, access-levels
row, WoL cross-link. openapi/header/SDK regenerated.
2026-08-28 21:54:15 +02:00
enricobuehler aa8c5d0e0e The retry loop stops eating the restore that re-lights the desk
`topology: exclusive` on Hyprland left the operator's heads disabled after a
failed session, recoverable only by a hand-run `hyprctl reload`. The cause is
neither a failing reload nor the registry: it is one line in `apply_topology`.

The host opens the vdisplay backend ONCE and lends it to
`build_pipeline_with_retry` for up to eight attempts, so `create` — and with it
`apply_topology` — runs repeatedly against one instance's `pending_restore`
slot. Attempt 1 disables the heads and prepares the restore; attempts 2..n then
correctly find nothing left to disable (attempt 1 already darkened everything)
and prepare `None`. The slot was assigned unconditionally, so attempt 2 dropped
attempt 1's closure on the floor. When the build finally failed and the backend
dropped, its `Drop` had nothing to run — which is exactly why the field journal
shows `disabled=[...]`, ten `nothing to disable`, and no `restore_heads` line of
either polarity.

`stash_topology_restore` makes the slot first-wins. First is also the RIGHT
list, not merely the surviving one: attempt 1 looked at the desk while it was
still lit, so its set is every head that was on; a later attempt can only see a
subset.

Two corrections to what the code claimed about itself, since they are what made
this read as a registry bug. The Hyprland and sway `pending_restore` docs said
the registry picks the restore up and `Drop` is a mere backstop. It does not:
both backends carry a portal fd, so `registry::acquire` returns them as
pass-through at the `remote_fd.is_some()` check, several statements ABOVE its
`take_topology_restore()` call. Nothing ever lifts these into a display group,
and `Drop` is the only thing that runs them. The per-group hand-off machinery
is real, tested and correct — it just serves KWin, not these two.

That leaves a genuine per-session restore on the wlr-family backends (two
concurrent exclusive sessions: the first to end re-enables the heads under the
second), which is now written down at the field rather than contradicted there.
Closing it needs group bookkeeping the pass-through path does not have, so it
stays #284's call, not a drive-by restructure.

KWin gets the same guard although its slot is drained by the registry after
every create, so it cannot strand today — one of three identical sites left
unguarded is how this comes back.

Four tests in `backend.rs` cover the failure shape, the one that matters being
eight attempts with only the first having heads to disable. Verified by backing
the guard out: it fails with "the retry loop stranded the restore — the desk
stays dark".
2026-08-28 21:29:57 +02:00
enricobuehler 78723f381e Merge pull request 'The advert names its address, and the client stops rolling dice on the rest' (#429) from worktree-mdns-primary-addr into main
Reviewed-on: unom/punktfunk#429
2026-08-28 19:10:15 +00:00
enricobuehler 46e617f7e6 Merge pull request 'Omarchy becomes a first-class host, and the host grows a control surface' (#428) from worktree-omarchy-impl into main
Reviewed-on: unom/punktfunk#428
2026-08-28 19:07:32 +00:00
enricobuehler 9c64cc6099 The ticket parser proves its own segments exist
`tsc --noEmit` under `noUncheckedIndexedAccess`: destructuring `ticket.split(".")` yields
`string | undefined` per element, and `parts.length !== 3` does not narrow a plain array — so five
type errors on the regex tests and the compare. An explicit `!ts || !nonce || !mac` guard proves it
to the compiler AND rejects the empty segments a `"1..2"` ticket would otherwise reach the regexes
with, which is what the removed `parts.some(p => p.length === 0)` was doing less directly.

Caught by CI's `web` job, not by me: I ran `bun test` and biome and stopped there. The typecheck
needs `node_modules`, which a fresh worktree has none of, and I took the two h3 import failures as
"pre-existing" without noticing that the same missing install was hiding the type check entirely.
With the deps in place the full web suite is 36/36 and `tsc --noEmit` exits 0.
2026-08-28 20:46:33 +02:00
enricobuehler 33914867d1 The advert names its address, and the client stops rolling dice on the rest
A field client streamed over the host's ZeroTier address while both
machines shared a LAN. Neither end chose that: the host registers
exactly one address (its routed primary), but the OS's own mDNS
responder also answers A queries for <host>.local. per interface with
that interface's address, so the resolved set a client folds is a union
polluted by every overlay network whose multicast reaches it - and both
clients picked with HashSet::iter().next(): arbitrary, and re-rolled on
every re-announce.

punktfunk_core::discovery now makes the pick deterministic, shared by
the desktop and Android clients. Ranking, best wins: longest common
prefix with any of this machine's unicast addresses (on-link beats
routed, which settles LAN-vs-overlay in BOTH directions - a client that
can only reach the host through the overlay has no interface for the
host's LAN address to match), then the address the host itself declared,
then prefix against our default-route source, then the numerically
lowest. The host declares which address its advert is FOR in a new
advisory TXT key (addr); it never overrides reachability, only settles
a multi-NIC host's tie, and old hosts without it still resolve
deterministically.
2026-08-28 20:34:34 +02:00
enricobuehler 38d2b0e454 The console follows the desktop's theme instead of ignoring it
WP7 shipped a template and no consumer. `punktfunk.json.tpl` rendered fine — Omarchy globs
`~/.config/omarchy/themed/*.tpl` and writes the result to
`~/.local/state/omarchy/current/theme/` on every `omarchy-theme-set`, and the keys the template
asks for (`mode`, `accent`, `background`, `foreground`) all exist in a theme's `colors.toml`,
verified against tokyo-night on the box. Nothing read it, so nothing changed, which is exactly what
"the theme is not applied" looks like from the outside.

Now: the server reads the rendered file and `ui-config` carries it; the root applies `mode` as the
`.dark` class the whole stylesheet already keys off, and `accent` as the brand variable
`--primary`, `--accent` and `--ring` all derive from. One value re-tints the buttons, the active
nav and the focus rings together.

BOTH brand variables, not just `--pf-brand`. The light palette derives `--primary` from it, but
`.dark` derives `--primary` from `--pf-brand-light`, and the console ships pinned dark — so setting
only the first would have re-tinted light mode and done nothing whatsoever in the mode people
actually see.

The accent reaches a style attribute, so the reader validates it as a colour rather than trusting
the file: hex and the functional notations Omarchy themes use, nothing else. That is not
theoretical tidiness — an UNRENDERED template contains the literal `{{ accent }}`, and a theme
switch can be caught mid-write. Both are "no theme" here, and no theme means the console's own
violet, which is also what SSR paints and what shows for the moment before the fetch resolves.
Every failure is the fallback; none is an error page.

Read per request rather than cached, because `omarchy-theme-set` rewrites the file whenever the
user switches and a console that only looked at startup would be wrong until it restarted.

Verified on the box: the template renders (`"mode":"dark","accent":"#7aa2f7"`), and the reader
parses that exact file. 14 tests, biome clean.

⚠ Like the handoff, the console half needs a `punktfunk-web` build to reach a box — it ships as a
prebuilt Nitro `.output` and the Omarchy testbox has no bun — so this is unit-tested and
file-verified but has not painted a pixel on glass.
2026-08-28 20:25:11 +02:00
enricobuehler 30c31ed31b Opening the console from your own launcher no longer asks who you are
Three things an Omarchy user meets in the first minute, all of which were wrong.

The login (the handoff)
-----------------------
Asking for a password to open your own console from your own app menu buys nothing — but the
obvious fix, dropping the login, is not available: the console binds **0.0.0.0:47992** so it can be
reached from a phone, and its admin surface is pairing, unpair and session control. Trusting the
network would hand that to the LAN.

So trust what actually distinguishes the operator: the **management token**, a 0600 file inside the
0700 config dir. `punktfunk-host ctl console-url` mints a ticket signed with it; the console
verifies with the copy it already holds and seals the ordinary session cookie. Whoever can read
that file can already drive the whole admin API — it is the credential the console's own proxy
presents — so skipping a password they could simply read widens nothing, and a visitor without a
ticket still meets the login page.

`<unix-seconds>.<nonce>.<HMAC-SHA256>` over `pf-console-handoff:v1:ts:nonce`. 60 s TTL, single use,
symmetric window (a ticket from the future is as wrong as an old one), one 401 for every rejection
so probing learns nothing. No new host route and no shared state: both sides already hold the key.
The decision lives in `util/handoff` rather than the route so it is testable without an h3 event —
and the test suite includes a vector minted by the real Rust host and cross-checked against
python's `hmac`, so a drift in the message format fails here instead of six weeks later in the
field.

The launcher entry
------------------
`Icon=` was empty, so the entry drew nothing. Two causes: the host package never shipped an app
icon (only the CLIENT package installed `io.unom.Punktfunk.svg`), and `omarchy-webapp-install`
derives an icon name with `${ref%.*}` + slugify, which turns a dotted theme name into `io-unom` —
resolving to nothing. The host package now ships the scalable mark, and setup writes `Icon=` and
`Exec=` itself after letting their tool create the entry.

The Omarchy menu
----------------
Super+Space had only the webapp. There is now a Punktfunk submenu — open console, pair a device,
devices waiting, paired devices, stop the session, status, restart — with `when` guards so a row
the box cannot honour does not show.

D7 avoided writing the user's `omarchy-menu.jsonc` because it is a single document where one parse
error silently drops EVERY row they own. That risk is real, so the merge earns its place: work on a
copy, insert between markers so a re-run replaces rather than stacks, validate the result as JSONC
AND confirm our rows actually landed, and only then move it into place. A file that does not parse
to begin with is left completely alone — it is not ours to repair. `remove` takes the block back
out and restores their file byte for byte.

Two bugs the self-check caught before the box did: `awk -v` refuses a newline inside a `-v`
assignment, so the rows silently never landed while the function reported success (hence the merge
now verifies its own output, not just that the file still parses); and `MENU_FILE` was a `readonly`
computed at LOAD time, so it ignored an `XDG_CONFIG_HOME` set afterwards and edited the real config
of the machine running the test. Paths resolve at call time now, like `write_hooks` already did.

Gates: fmt, clippy -D warnings, 13 ctl tests, 7 handoff tests, 13 shell self-checks, docs-drift,
shellcheck. On the box: the ticket verifies against an independent HMAC, two tickets in the same
second differ, and the launcher entry carries the right Exec and Icon.

⚠ The console half needs a `punktfunk-web` build to reach a box — it ships as a prebuilt Nitro
`.output`, and there is no bun on the Omarchy testbox — so the route itself is unit-tested but has
not served a request on glass.
2026-08-28 20:14:00 +02:00
enricobuehler 739d63fc04 The picker is handed back when the host exits, not between casts
Restoring `custom_picker_binary` at the end of every cast meant the NEXT session found the config
changed, rewrote it, and restarted xdg-desktop-portal-hyprland — and a ScreenCast bound across an
xdph restart never delivers a buffer. The portal runtime caches its D-Bus connection
process-globally (`portal_thread`'s own note), so the restart orphans the cached connection and the
handshake then succeeds against a session nothing is alive to serve. That is the failure our own
error text already names: "a stream bound during a compositor (re)start that will never deliver".

So the restore moves to the host's shutdown path, through the `restore_takeover_now` hook the host
already calls on SIGTERM — no cast is live there, so the xdph restart it triggers costs nothing.

Leaving the shim installed between sessions is safe precisely because it DELEGATES: with no
selection pending it execs the picker that was configured before us, so an ordinary browser share
behaves exactly as it did. That is what D6 actually asks for — the user's screen sharing keeps
working — and it is what lets the takeover be idempotent instead of churning the file. A host that
is SIGKILLed still leaves the shim, and `punktfunk-omarchy remove` puts the original back from the
marker.

Found while chasing a black screen on Omarchy that turned out to have a different root cause (the
VM had two GPUs and the portal allocated on the wrong one), so this fixes a real defect that was
not the one being hunted — every session after the first would have restarted xdph underneath
itself regardless of the GPU topology.
2026-08-28 20:14:00 +02:00
enricobuehler c7d270b466 The streamed head can be focused on a Lua box, which is what makes it produce frames
#283's focus fix has never reached a Lua-configured Hyprland. The old note here said why and then
stopped: `hyprctl dispatch focusmonitor <name>` is parsed as `hl.dispatch(focusmonitor <name>)`
and dies with `')' expected near '<name>'`, and `hl.dsp.focusmonitor` does not exist. Both true.
The name was simply wrong.

Measured on Omarchy 4.0.1 / Hyprland 0.56.2, the spelling is

    hl.dsp.focus({ monitor = "<name>" })

and the compositor volunteers it — ask with any other key and it answers "hl.focus: unrecognized
arguments. Expected one of: direction, monitor, window, urgent_or_last, last". Same two-era shape
as `dpms_one`: try classic, then Lua, report both if neither lands. Each spelling is rejected by
the other's parser, which is what makes running them blind safe.

This is not the cosmetic window-placement issue it was filed as. A headless output nothing has
focused stays empty; an empty output produces no damage; no damage means no PipeWire frames — and
capture then misses its first-frame deadline:

    pipeline build failed — retrying attempt=4 max=8
    error=first frame: no PipeWire frame within 10s (node 85): format negotiated but no buffers
    arrived — the compositor produced no frames

i.e. a black screen on the client, on every Omarchy box.

It is only HALF the black screen seen on glass, and the other half is NOT fixed here. The same
session had already run `topology: exclusive`, which logged

    `topology: exclusive` — the streamed output is now the desk disabled=["Virtual-1","HDMI-A-1"]

twice, and then never restored them: `restore_heads` logs on success AND on failure, and neither
line appears anywhere in the journal, so it was never called on the pipeline-build failure path.
The ten retries that followed all reported "had nothing to disable" — correctly, the heads were
already off — so once the first attempt fails the desk stays dark and every later attempt fails
for the same reason. `hyprctl reload` is the recovery. That belongs to #284's in-flight work
rather than here, so it is recorded with its evidence instead of half-fixed in a file another
effort is editing.
2026-08-28 20:14:00 +02:00
enricobuehler b8720e5e4a The Omarchy box says otherwise: five things the plan got wrong, measured
WP0 + S2 run against a real Omarchy 4.0.1 box (Hyprland 0.56.2, Quickshell 0.3.1, RTX 5070 Ti).
Everything below is a measurement, not a re-reading of the docs.

Fixed, each one a first-run defect
----------------------------------
1. **The install line could not work.** Omarchy ships a libalpm PreTransaction hook
   (00-omarchy-update-guard) that aborts any transaction whose pacman invocation carries BOTH
   -S and -u, to funnel upgrades through `omarchy update`. So the Arch line this branch shipped
   for Omarchy — `pacman -Syu <pkgs>` — dies with "Woah partner..." and installs NOTHING. Seen
   in /var/log/pacman.log, then confirmed the other way: `pacman -Sy` + `pacman -S <pkgs>`
   installs all three cleanly. platforms.json, the docs page and install.sh now split the two,
   and plain Arch keeps its full -Syu (a partial upgrade against a ROLLING repo is what breaks
   those boxes; Omarchy's frozen snapshot mirror is exactly why it does not break here).

2. **The idle guard never restored anything.** `omarchy-toggle-idle status` prints JSON, not a
   keyword — and the payload is a trap: its tooltip names the action the button WOULD take, so
   the string "Stay Awake" appears precisely when stay-awake is OFF. The old case-glob matched
   neither that nor the words it was looking for, so every arm fell through to "do nothing" and
   the box stayed awake after every session. Now reads `"enabled":true`. Verified both ways on
   the real tool: a user's own stay-awake survives a stream, one we set is undone.

3. **`status` lied twice.** It reported "no punktfunk rules" seconds after adding six of them
   (a failed `sudo -n` read was being treated as proof of absence), and reported xdph missing on
   a box that was capturing through it — `pgrep -x` can never match `xdg-desktop-portal-hyprland`
   because Linux truncates comm to 15 characters, and pgrep says so on a stderr the check threw
   away. Now: never claim absence from a failed read, and ask systemd about the portal.

4. **D3's premise is only half true.** Omarchy ships TWO session entries. On `hyprland-uwsm`
   graphical-session.target starts; on plain `hyprland` it never does — measured, and Omarchy's
   OWN units that want it (omarchy-crash-watch, omarchy-sleep-lock) sit enabled-and-dead there
   too. The drop-in is additive so autostart still works either way, but "restarts with your
   session" was a promise we could not keep. setup and status now detect and say which it is,
   and the docs explain when switching sessions is worth it (rarely: the host re-derives the
   live compositor per connect).

5. **`ctl watch` leaked a stream per dead consumer.** A watcher only ever READS, so when the
   widget died its end of our stdout pipe closed unnoticed: six orphans accumulated across three
   shell restarts on a host with no events at all. The SSE keep-alive now surfaces as a
   `ctl.heartbeat` line, which gives us a write to fail on, and a failed write ends the process.
   Verified with its control: dies ~13s after its reader goes, survives 50s while the reader lives.

Confirmed as designed
---------------------
* **D6 end to end.** The box's xdph.conf is exactly the shape the fix targets. A real capture
  (xdph → PipeWire 1920x1080 → cuda-import → nvenc) installed the shim, the shim picked up
  `hyprland-preview-share-picker` as its fallback automatically, the restore fired on teardown,
  and the file came back byte-identical with no marker left. The landmine is defused.
* **The pin correction.** The mgmt port serves 306d95… = native-cert.pem; cert.pem is 34a4cb…,
  a different certificate entirely. Pinning cert.pem as the plan said would have exited 4 on
  every healthy host. All four exit codes exercised on glass — 4 by pointing ctl at the host's
  own legacy cert, which is a valid certificate that simply is not the one mgmt serves.
* **R1 and R3 closed.** Our soname depends (libavcodec.so=63-64 …) match the frozen snapshot
  mirror's ffmpeg 9.0.1 exactly, and /etc/pacman.conf is owned by pacman, not omarchy-settings,
  so our repo stanza is not clobbered. R4 closed by (1); R7 closed — enforce_permissions is off
  as shipped.
* The tree builds on Omarchy in 8m16s against the snapshot's toolchain (rustc 1.98).

Gates: fmt, clippy -D warnings, 13 ctl tests, docs-drift, shellcheck, 8 shell self-checks.
2026-08-28 20:14:00 +02:00
enricobuehler 32a89fd8ec Two checks for the two failures that report nothing at all
Diagnostics catalog rows for the console's troubleshooting page (design §3.M, WP1).

`hyprland_permissions` — Hyprland 0.49+ can enforce per-application permissions, and when
it does, screencopy and virtual input are denied SILENTLY: black frames and dead input,
with no error from the host and none from the compositor. Every other failure on that path
announces itself; this one is the reason the row exists. Warning rather than Critical on
purpose — enforcement being ON does not mean we are denied, a granted host streams fine,
and no probe outside the compositor can tell the two apart. Not Omarchy-specific: it
applies to every Hyprland box, and Omarchy is merely the distro most likely to turn it on.

`omarchy_updates` — on Omarchy the console's apply button is deliberately absent (D5).
Without a row saying where updates come from instead, "my update button is missing" is an
unanswerable support question, asked in the one place we could have answered it.

Both ids are registered in the documented-ids test, so a rename is caught in review rather
than in a bug report about a check that suddenly renders in English.

Host suite: 678 pass. The one red — gamestream::stream::tests::sender_delivers_batches —
is the documented EINTR flake in this qemu container: it fails identically with these
changes stashed, it lives in a file this branch never touches, and the panic is literally
"Interrupted system call".
2026-08-28 20:14:00 +02:00
enricobuehler d9cb3ce701 The host grows a control surface, and Omarchy stops being a distro we merely tolerate
Implements the Omarchy integration plan (punktfunk-planning design/omarchy-integration.md
+ omarchy-integration-implementation-plan.md): the ctl surface M-C1..C4, design WP1-WP7,
and the D6 xdph landmine. WP0 (the measurement battery) and WP11 (upstreaming) are not
here — the first needs an Omarchy box, the second needs an explicit go.

`punktfunk-host ctl` (WP8 / M-C1..C4)
------------------------------------
A subcommand, not a second binary. The entry-point analysis is in the plan's §3; the
short version is that a new binary touches every Linux artifact we ship to buy nothing,
while `main.rs` already dispatches a dozen verbs and in-crate means no second declaration
of the mgmt types to drift.

15 verbs over the existing admin lane — pairing arm/pending/approve/deny, the Moonlight
PIN, both planes' device lists with rename/unpair/access presets, session stop/end-game,
and `watch`, which bridges the SSE stream to line-JSON on stdout with `Last-Event-ID`
resume and a synthetic `ctl.resync` line after a `dropped` frame. `--json` on every verb
returns a versioned envelope; the human tables are not parsed by anything we ship.

The security model is the plan's §§1-2, and the load-bearing part is **pin before token**:
the agent pins the host's own leaf (via punktfunk-core's canonical `PinVerify`) and rustls
rejects a mismatch *during the handshake*, so ureq never gets as far as serialising an
`Authorization` header. That closes the local port-squat vector with zero server-side
change — `mgmt/auth.rs` is untouched by this whole surface. There is deliberately no
`--token` flag and no token env read: a credential in either is readable cross-uid through
/proc, which is what the 0700 config dir exists to prevent. ctl consumes the token the
host persists and never mints one (the `web-password` silent-adoption lesson, inverted).

Two corrections to the plan, both from reading the code rather than the doc:
 * the mgmt listener serves the NATIVE identity, so the pin is `native-cert.pem` then
   `cert.pem` — pinning `cert.pem` alone would exit 4 on a perfectly healthy host;
 * the pin is a SHA-256 fingerprint compare through the existing audited verifier, not a
   new bytes-compare. Same property, one less thing to review.

The test that matters is the negative: a squatter presenting a valid, well-formed,
wrong self-signed certificate gets exit 4 and receives ZERO application bytes.

The xdph collision (D6)
-----------------------
Omarchy ships its own `~/.config/hypr/xdph.conf` naming `hyprland-preview-share-picker` —
the picker every browser share on the box goes through — and our Hyprland backend takes
`custom_picker_binary` over on first connect. The merge half was already right (one key,
in place, with a backup); the way back did not exist, so a box that had ever streamed kept
our shim forever, including after a crash and after a reboot.

Two changes, and the first is the one that does not depend on getting teardown right: the
shim now DELEGATES — with no selection pending it execs whatever was configured before us,
so ordinary browser shares behave exactly as they did, during a session, after it, after a
kill and after a reboot that emptied $XDG_RUNTIME_DIR. Second, the edit records what it
replaced (a marker comment written in the same atomic write, so it survives everything a
sidecar state file would not), and the picker is handed back when the last cast ends.
`punktfunk-omarchy remove` restores it too, from the same marker.

The rest
--------
 * WP1 — `osinfo::is_omarchy()`; the console update tier is notify-only on Omarchy and
   names `omarchy update`. Enforced at the apply route, not merely reported: a direct POST
   on a box with the helper, the group and the sysupgrade opt-in would otherwise run
   `pacman -Syu` into their guard, or past it, skipping the snapper snapshot their rollback
   depends on.
 * WP2/WP3 — `punktfunk-omarchy setup|remove|status`, installed but never run by the
   package (packages do not open firewalls or edit user config behind an operator's back).
   LAN-scoped comment-tagged ufw rules over our existing app profiles, the session drop-in
   that Omarchy's uwsm actually starts, the console as a webapp rather than an edit to the
   user's single menu JSONC, and a Sunshine-coexistence check that says plainly which plane
   collides. PKGBUILD gains the hyprland/xdph optdepends that were owed before Omarchy.
 * WP4 — docs/omarchy.mdx, the ctl reference in host-cli.md, platforms.json (+ snapshot),
   the Hyprland/Omarchy rows in requirements.md, an honest "not verified on glass" row in
   the support matrix, and an install.sh branch that hands over to `punktfunk-omarchy setup`.
 * WP5/WP6/WP7 — sample hooks wired through hooks.json (never host.env: hooks are a list,
   and an operator's existing file is printed to rather than overwritten), an idle guard
   that snapshots and RESTORES the user's own stay-awake setting rather than assuming, and
   the theme template.

Gates: fmt, clippy -D warnings on punktfunk-host and pf-vdisplay's Linux half, 45 host
tests, 267 pf-vdisplay tests, check-docs-drift, shellcheck, and 8 shell self-checks
covering the awk picker restore and the hooks.json generator against the same fixtures
the Rust round-trip tests use.

Not verified on an Omarchy box — nothing activates without `punktfunk-omarchy setup`, so
a plain Arch box is unaffected either way.
2026-08-28 20:14:00 +02:00
enricobuehler d12d494f68 Merge pull request 'The client stops promising an HDR its video processor can't tone-map' (#427) from worktree-arc-pq-tonemap-probe into main
Reviewed-on: unom/punktfunk#427
2026-08-28 12:31:46 +00:00
enricobuehler abac2a6802 Merge pull request 'A truncated roundtrip is not an encoder ceiling, and the probe waits for video' (#426) from worktree-abr-phantom-ceiling into main
Reviewed-on: unom/punktfunk#426
2026-08-28 12:31:18 +00:00
enricobuehler 2b46b5c2da Merge pull request 'A deploy under an open tab stops taking the next navigation with it' (#424) from worktree-worktree-web-stale-assets into main
Reviewed-on: unom/punktfunk#424
2026-08-28 11:11:44 +00:00
enricobuehler 224ed86fbd A truncated roundtrip is not an encoder ceiling, and the probe waits for video
Two field sessions on 0.32.0 (Windows client over ZeroTier, Android over
Wi-Fi) plus a local repro showed every Automatic session ratcheting
20 -> 2.3 Mbps on the first loss episode and never climbing back.

Phase 4 made the bitrate a wire budget: the encoder is handed
enc_kbps(budget) and the read-back maps through budget_kbps(). That
roundtrip deflates 1-2 kbps by design (the inverse never inflates), and
the applied < requested compare then recorded a phantom
encoder_ceiling_kbps on EVERY successful apply. The control task clamps
each later SetBitrate to it, so every climb was acked short at the
current rate, the client controller learned it as a host cap
(reprobe backoff 16 -> 32 windows), and each descent lowered the
"ceiling" further: a one-way ratchet to the floor. 0.31.4 was immune -
its read-back stayed in encoder units, so an in-range apply compared
equal. EncDerive::applied_budget_kbps now reads back in the request's
own truncated terms: only an apply short of what the request itself
derives to - a genuine driver clamp - records a ceiling. Regression
test proven against the backed-out fix.

The startup link-capacity probe fired on a flat 2 s timer, but host
bring-up can take 6-8 s (IDD display acquisition), so the 940/464 Mbps
burst landed exactly on the first IDR - black video for 5-11 s, the
decoder refusing AUs ("PPS 0 not seen"), 2101 audio underruns - and the
result missed the 6 s timeout anyway (bring-up plus the burst's own
queue drain and QUIC loss-recovery backoff: 8.6 s measured), so the
ceiling stayed negotiated: all disturbance, no measurement. The probe
now arms only once a frame has completed, and the timeout is 15 s so a
slow answer still teaches the ceiling.
2026-08-28 10:29:15 +02:00
enricobuehler a86edba4ca fix(client): don't advertise HDR a video processor can't tone-map — the green-monitor Arc report
VIDEO_CAP_HDR invites a PQ stream, and on a Windows box with no HDR10
swapchain the D3D11VA hand-off shows it through the video processor's
PQ->sRGB tonemap — a driver capability nothing ever validated. The Blt
accepts the colorspaces either way and renders garbage where the
conversion is missing (host-side sibling: NVIDIA RGB->P010 'renders
green'). Field 2026-08-26: an Arc A370M client went green on every HDR
session while AV1 8-bit SDR at the same 2880x1620@120 streamed clean.

video_d3d11 gains pq_tonemap_supported() — one throwaway device +
enumerator asked CheckVideoProcessorFormatConversion for the exact pair
the SDR ring sets (P010 G2084 studio in, BGRA8 sRGB out). Only the
driver's definitive 'no' answers false; API failure keeps today's
behaviour. video::hdr_presentable() wraps it behind the short-circuits
that make it free everywhere it doesn't matter (!d3d11_import,
d3d11_hdr10 pass-through, non-Windows shader tonemap), and the session
binary ANDs the HDR setting with it exactly like the 4:4:4 promise —
loud warn when the switch is on and not honored. ten_bit_sdr stays
unprobed on purpose: 10-bit SDR is no tonemap.

Also: log_layout_once keys on decoder x layout instead of decoder alone,
so a mid-stream Reconfigure or SDR<->PQ flip re-logs the frame-vs-pool
dims — the fact a green-bar/smear report hinges on — for the shape that
failed, not just the one the session started at.

Verified: cargo fmt + dockerized Linux clippy -D warnings green for
pf-client-core + punktfunk-client-session; the cfg(windows) half needs
the Windows CI leg (not compilable from this box).
2026-08-28 09:02:59 +02:00
bluelightspecial 16ba8713ad feat(android,sc2): frozen-timestamp IMU gate on the raw report feed
Ports the IMU liveness gate to the Android client so both clients apply the
same policy: zero the IMU block while its timestamp is frozen, pass it through
once it advances. Pure JVM tests pin the truth table.
2026-08-27 22:28:42 -04:00
bluelightspecial 1d71df51a4 feat(apple,sc2): CoreBluetooth Steam Controller 2 capture and IMU gate
Captures an OS-paired Steam Controller 2 over its custom Valve GATT service
and forwards raw reports to the host's virtual pad, with a settings toggle and
an on-stream passthrough badge.

The custom service coexists with the OS's own HID-over-GATT binding, so no
unpairing is needed. Two framing rules matter: a characteristic value carries
no HID report-id byte, so 0x45 is re-prepended on the way up; and output
reports are routed per id to their own characteristic at 100F6C<id+0x35>, with
the id stripped from the payload.

The IMU block streams frozen at a stale non-zero resting sample until Steam
enables the gyro, which reads as constant rotation and flies the desktop
cursor — so the block passes through only while its timestamp is advancing.
All framing and table logic is CoreBluetooth-free and unit tested; only the
transport shim touches the radio.
2026-08-27 22:28:42 -04:00
bluelightspecial 31002f70f2 feat(core,host): raw HID passthrough over the client ABI, SC2 routed to Triton
Adds send_hid_report and the HidRaw host-to-client return path to the client
C ABI (ABI_VERSION 26 -> 27, a breaking bump), and stops the Windows host
folding a captured Steam Controller 2 down to an Xbox 360 pad — the SC2 kind
now reaches the Triton backend as-is.

Also adds a --triton bench pad to the host devtest harness, for driving the
claim path without physical hardware.
2026-08-27 22:28:42 -04:00
bluelightspecial 771c5b24a4 feat(driver,inject): pf_triton hardware id and the Windows virtual-pad backend
Gives the pf-gamepad UMDF minidriver an eighth identity, pf_triton, with the
Triton USB/HID descriptors and an in-driver answer to Steam's feature query
dance, and adds the pf-inject backend that drives it over the existing
shared-memory ring.

Output frames are trimmed to their declared per-id length before forwarding,
so a 0x80 rumble goes out as 10 bytes rather than a 64-byte padded write.
Feature SETs are tagged in the ring's length word so the drain can tell them
from interrupt output.

Triton delivery is EVENT-DRIVEN: a read completes only when the input
latch actually changes, where every other identity keeps the 2 ms
every-tick cadence. The pad carries the physical controller BLE reports
and iOS floors the connection interval at ~15 ms, so re-serving the latch
every tick handed Steam ~7 identical reports followed by one holding a
full 15 ms of trackpad travel — read as a flick ~7x faster than the finger
made it (runaway trackball momentum, bench 2026-08-23). Real hardware NAKs
the interrupt IN when it has nothing new; leaving the read pended is this
stack equivalent.
2026-08-27 22:28:42 -04:00
bluelightspecial f3821331ac feat(driver-proto): DEVTYPE_TRITON and the shared Steam Controller 2 wire tables
Adds the Triton (2026 Steam Controller, Valve 28DE:1302) device type to the
shared no_std driver protocol crate: the captured 372-byte HID report
descriptor, the per-id output-report length table, and the feature-report
query dance Steam's GetControllerInfo runs (0x83 attributes, 0xAE string,
0xF2 firmware info).

The reply must echo the last SET's command byte or Steam treats the pad as
broken and never adopts it. Identity values served to Steam are synthetic by
construction: the unit id is "TRI\0" | index, the serial is FVPF-prefixed so
the host's physical-controller conflict gate excludes it, and the firmware
build time is pinned to a recent date so Steam does not offer to "update" a
virtual pad.

Descriptor and per-id map were established against physical hardware on the
bench (2026-06-08/09).
2026-08-27 22:13:42 -04:00
enricobuehler 23f53d46a7 chore(web): biome 2.5, and the findings it surfaced
biome is not in ci.yml's web gate, so formatting and import order had drifted; `biome check
--write` accounts for most of this diff. `biome migrate` moved the deprecated
linter.rules.recommended to rules.preset. public/ and storybook-static/ are excluded — biome
was linting the favicon as if it were inline JSX and asking it for a <title>.

Three findings were real:

- Library lifted the whole entry list into state so the providers card could count owners.
  That consumer is gone: `entries` was written on every grid load and read by nothing. The
  state, the `onEntries` prop threading it up, and the effect firing it are all removed —
  including from LibraryGrid, where the callback had no callers left.
- `flatten` in SourceSettings spread the accumulator per `allOf` branch, rebuilding the object
  each time. Object.assign onto an accumulator that already starts as a fresh copy of the node
  is the same result without the O(n²).
- LogsCard's `newestVisible` dep already carried a comment begging the next person not to
  "fix" it — it is a trigger, not a read, and removing it silently breaks follow-mode. That
  warning is now a biome-ignore, so the tool enforces it instead of prose hoping to.

Build, tsc, 22 tests and biome all clean.
2026-08-28 02:23:40 +02:00
enricobuehler f693edda20 refactor(web,docs-site): take the stale-chunk recovery from @unom/ui instead of keeping our own
@unom/ui has exported `reloadOnStaleChunk` for exactly this the whole time and nothing had
ever imported it — the copy added a few commits ago was a fourth implementation of a solved
problem. Both routers now call the library.

0.10.0 grew the two things using it turned up: an optional `resolveTarget`, so the recovery
lands on the page the user was navigating to rather than reloading in place, and a try/catch
around the sessionStorage guard, because blocked storage throws on `getItem` and that throw
escaping the listener leaves the tab on exactly the dead page the helper exists to prevent.

The helper installs once, so the callback reads a module-level ref rather than closing over a
router: hydration builds a second router and discards the first, which is the same reason the
console keeps its QueryClient in one.

Deps: @unom/ui 0.10.0 and motion 13.1 (0.10.0 widened the peer, which is what had pinned us to
12), plus @unom/app-ui 0.3.0 for the docs site.

docs-site: build + tsc clean, and the deploy simulation still recovers onto /docs with the
library helper in place. web: build + tsc + 22 tests clean, bun.nix regenerated.
2026-08-28 02:23:25 +02:00
enricobuehler a3e073fd3a Merge pull request 'The host-audio switch reaches every client, not just the desktop ones' (#423) from worktree-keep-host-audio-parity into main 2026-08-27 23:26:41 +00:00
enricobuehler 71454da082 chore(deps): bring the docs site and console up to Vite 8 and TypeScript 7
Everything in range moves to current — TanStack router/start/query, fumadocs, radix,
recharts, orval, storybook, biome, paraglide, playwright — and the majors that were
holding both apps a generation back go with them: Vite 7 to 8, @vitejs/plugin-react 5 to
6, vite-tsconfig-paths 5 to 6, @types/node 22 to 26, TypeScript 5.9 to 7. The marketing
site already ran the Vite 8 line against TanStack Start, so this closes a split the
monorepo was carrying for no reason.

Two of the console's majors were fixing a mismatch rather than chasing a number:
@unom/ui@0.9.2 asks for lucide-react ^1.17 and depends on tailwind-merge ^3.6, while the
console was pinned to lucide-react 0.469 and tailwind-merge 2.6.

motion is deliberately left on 12.43. 13.1 is available, but @unom/ui declares motion
^12.40 as a peer and inlines framer-motion 12.38, so moving the app alone would put two
motion runtimes in one tree — and a split motion context is exactly the failure that
neither a typecheck nor a build can see. It moves when @unom/ui does.

docs-site: build + tsc clean. web: build + tsc + 22 tests clean, bun.nix regenerated.
2026-08-28 01:24:21 +02:00
enricobuehler 263e173604 fix(web,docs-site): a deploy under an open tab stops killing the next navigation
Routes are code-split, so every build names its chunks by content hash and a deploy
replaces the whole `.output`. A tab opened before the deploy still holds the previous
build's HTML, so the first navigation asks for `/assets/*-<oldhash>.js` — which the new
server has never heard of. The dynamic import rejects, nothing catches it, and the page
goes blank. `defaultPreload: "intent"` widens the window: a hover is enough to trip it.

Vite raises `vite:preloadError` for exactly this, and its preload helper wraps both the
dependency preloads and the module import itself, so the event fires before the router
ever sees the rejection. A full page load is the entire fix — the fresh HTML names the
new chunks. Navigating to `latestLocation` rather than reloading in place means a click
that tripped this still lands on the page the user asked for.

Deliberately not `preventDefault()`: that suppresses Vite's rethrow and resolves the
import with `undefined`, handing the router a broken module on the way out. A
sessionStorage stamp keeps a genuinely broken deploy from spinning in a reload loop, and
its read is wrapped because storage can be blocked outright.

Verified on the built docs site under Playwright, serving 404s for the chunks an open tab
asks for: without the handler the page collapses to 32 characters and no <main>, with it
the tab reloads onto /docs and renders in full. Re-verified after the Vite 8 bump.
2026-08-28 01:24:05 +02:00
enricobuehler 93f0739160 The new cap constant is a wire bit, not a knob, so it joins the baseline
`check-docs-drift.sh` matches on the `PUNKTFUNK_*` shape, which cannot tell an
environment variable from a cbindgen-exported ABI constant — so exporting
`PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO` read as a new undocumented knob and
turned the job red. Every sibling is already listed for exactly this reason:
the four other `CLIENT_CAP_*`, all eight `HOST_CAP_*`, all eight
`VIDEO_CAP_*`. This one goes beside them, in sort order.

It is not documentation debt being waved through. The bit's user-facing half
is the "Keep host audio playing" row, already written up in client-settings.md
and pointed at from the `PUNKTFUNK_AUDIO_OUTPUT_MODE` table; the embedder's
half is the doc comment cbindgen carries into the header. There is no
configuration surface here to document, because there is no variable.
2026-08-28 01:12:45 +02:00
enricobuehler 59bdef5230 The host-audio switch reaches every client, not just the desktop ones
`keep_host_audio` shipped in 0.32.0 wired end to end — the setting, the
`CLIENT_CAP_KEEP_HOST_AUDIO` ask, the host's capture policy — but only three
surfaces ever offered it: the Linux GTK client, the Windows client, and the
desktop console. Apple and Android carried no reference to it at all, so the
one place a phone or an Apple TV would look for "why did my PC go quiet" had
nothing to find. There is no shared settings schema; each client hand-mirrors
`trust::Settings`, and this row simply never got mirrored.

Apple gains the full tier-P treatment: a `keepHostAudio` defaults key, the
`EffectiveSettings` field, the `StreamProfile` overlay (so a per-host profile
can keep the study PC's headphones live while the TV profile mutes them), the
Audio-section row, and the bit itself OR'd into the connect's `clientCaps`
beside the presenter caps. Android gains the same across `Settings`,
`Profiles` — including the `KNOWN` key set, without which the key reads as
unknown carry-through — the settings row, the console JSON both ways, and a
new `keepHostAudio` argument threaded through `nativeConnect` into the JNI
caps expression.

`pf-console-ui` had parked `RowId::KeepHostAudio` in `desktop_only` with a
comment saying "until the Android session advertises the bit". It does now, so
the gate comes off and the order-sensitive split test loses its entry.

The C ABI never exported the constant: `client_caps` is a passthrough byte, so
nothing needed a version bump, but cbindgen had no rename entry and emitted a
bare `CLIENT_CAP_KEEP_HOST_AUDIO` into every embedder's namespace — the exact
pollution R21 exists to prevent. It now carries the `PUNKTFUNK_` prefix its
`AUDIO_HIRES` sibling has, plus the compile-time drift assert.

Captions are the Linux client's wording verbatim, and the row sits between
audio quality and the microphone on all four clients, because a setting that
reads differently per platform is the same bug in a smaller font. The docs
line claiming "Linux, Windows and the desktop console" now says everywhere.

Verified: swift build + 384 Apple tests; `:app:compileDebugKotlin`;
`:kit:cargoNdkClippy` over pf-console-ui and the Android native crate; and all
205 pf-console-ui tests on Linux in a container, the split test among them.
2026-08-28 00:52:15 +02:00
enricobuehler 1391de87d6 Merge pull request 'A prep entry stops naming a device, and the docs show it' (#422) from worktree-prep-mode-env-example into main
Reviewed-on: unom/punktfunk#422
2026-08-27 22:50:03 +00:00
enricobuehler 3b0e880b4c docs(automation): a worked example for the prep mode env
v0.32.0 gave per-app prep do/undo steps PF_STREAM_WIDTH/_HEIGHT/_REFRESH/_HDR
(hooks::prep_mode_env, both serving planes), but the docs' only example was a
one-liner. Add the entry+script pair that shows the point: the prep entry stops
naming a device and reads the negotiated mode instead - an FPS cap at the
client's own refresh, an HDR arm taken only when the session negotiated it, and
the raster for whatever wants pixels.

Written so the traps are visible rather than explained: undo sees the values its
do saw, HDR is 1/0 (the marker file's spelling) not PF_EVENT_*'s true/false, the
app identity is PF_APP_ID or PF_APP_TITLE by plane, and set -u makes an older
host fail the step loudly instead of capping at 'fps_limit='.

Verified: the fenced script extracted from the doc and run under sh - do writes
the cap and takes the HDR arm, undo restores and reverses it, an SDR session
skips HDR, an empty identity falls through to the literal, and an unset
environment exits non-zero.
2026-08-28 00:45:17 +02:00
enricobuehler 7a6b930867 Merge pull request 'The 0.32.0 security wording says what actually happened' (#421) from worktree-release-notes-severity into main
Reviewed-on: unom/punktfunk#421
2026-08-27 21:24:50 +00:00
enricobuehler ababeafce3 docs(release): scope the 0.32.0 security wording to what it actually was
Two overstatements in the v0.32.0 notes, both flagged on reading the published
release.

**"anyone who could open your web console page"** was simply wrong, and it is the
sentence the Discord embed leads with. Reaching the pairing routes required an
ALREADY-AUTHENTICATED console session — it was privilege escalation inside the
console (those routes alone among the trust-root actions never re-asked for the
console password), not something an unauthenticated peer on the network could
touch. The notes now say so plainly, and say why it still mattered: pairing is
what grants launch, so a console sign-in became the ability to start a process on
the host.

**"58 security issues"** invites the reading that there were 58 exploitable
defects. There were 58 findings, and the long tail is minor hardening and
build-infrastructure tightening. Both documents now lead with that proportion
instead of the raw count.

Same correction applied to CHANGELOG.md in three places: the section intro, the
`Breaking changes` entry, and the security-review heading.

Nothing about the fix itself changed — only the description of its severity. The
tag does not need re-pointing: `announce.yml` checks out the ref it is dispatched
on rather than the tag, and `apply_release_notes` re-syncs the body from
`docs/releases/<tag>.md` there, so this lands in the announcement. The live
release body is PATCHed to match in the same pass.

Gates: the ISO 24495 auditor is back to its 2 standing findings (NVIDIA and AMD,
company names deliberately left unexpanded); check-docs-links.sh and
check-docs-drift.sh both exit 0.
2026-08-27 23:22:27 +02:00
enricobuehler ded8586123 Merge pull request '0.32.0 — the bitrate becomes the wire budget, and the connect ladder ends' (#420) from worktree-release-0320-prep into main
Reviewed-on: unom/punktfunk#420
2026-08-27 20:29:11 +00:00
enricobuehler 07af2f9e97 docs(changelog): record the nested-cargo doctest breakage in the CI section 2026-08-27 20:55:41 +02:00
enricobuehler 018c00e94b fix(core/c-abi): the harness builds into its own target dir, and stops breaking later doctests
`ci.yml`'s `cargo test --workspace --locked` has been failing at the very last
step for weeks, on a crate the diff never touched:

    Doc-tests pf_capture
    error[E0463]: can't find crate for `pf_frame`
      --> crates/pf-capture/src/lib.rs:11:5

`pf-frame` is an unconditional dependency of `pf-capture`, the rustdoc command
line carries `--extern pf_frame=<path>`, and Format, both Clippy legs and Build
had all compiled `pf-capture` seconds earlier. The path simply no longer existed
by the time rustdoc opened it.

`tests/c_abi.rs::ensure_staticlib` is what removed it. It shells out to a NESTED
`cargo build -p punktfunk-core --features quic` while the OUTER `cargo test` is
mid-run. That resolves features for one package instead of the workspace union
the outer run resolved, so cargo rebuilds punktfunk-core's subgraph under
different metadata into the SHARED target directory — and the outer run's
pending units, which name `target/<profile>/deps/*.rlib` by explicit `--extern`
path, are left pointing at artifacts that have been replaced. Doctests run last,
so they are what falls over.

The old comment asserted the opposite, and that is the bug in one line:

    // The outer cargo's build lock is released during test execution, so this is safe.

The released lock is why the nested build RUNS. It was never why it is safe.

Fix: build into `target/c-abi-harness/` and read the staticlib from there. The
nested build can no longer perturb the outer one, and the harness keeps doing
exactly what it did. Unconditional `--features quic` stays — that is `9c164aa6`'s
fix for a featureless `.a` being silently reused, and it is orthogonal.

Reproduced and verified on Ubuntu 26.04 (the CI base — 24.04's PipeWire is too
old to compile pf-capture's lib test), one variable at a time:

  cargo test -p punktfunk-core -p pf-capture --locked --no-fail-fast
    before                                 -> exit 101, E0463 on pf_frame
    before, --skip c_abi_harness_round_trips -> exit 0        (isolates the cause)
    after                                  -> exit 0, harness still PASSes
                                              (abi_version=26, 4 frames round-tripped)

Each from a wiped target dir. `cargo test -p punktfunk-core --test c_abi` also
passes on macOS, where the staticlib path moved.

Why it looked intermittent: whether the doctest step is reached at all depends on
what else in the workspace fails first, and `9c164aa6` made the nested build
unconditional — before that it ran only when the `.a` was missing.
2026-08-27 20:55:18 +02:00
enricobuehler 87cb431437 fix(capture/stall): the classification test's path overshot by one module
`super::super::stall::classify` in `idd_push.rs`'s test module resolves to
`windows::stall`, which does not exist — `mod stall;` is declared at
`idd_push.rs:332`, so `stall` is a child of `idd_push`, and the test module is
its sibling. A nested `fn` does not add a module level for path resolution,
which is what the extra `super::` was reaching for. The `use
super::stall::{ProbeWindow, StallClass, StallVerdict}` two lines above, in the
same test fn, already had the depth right.

Windows-only and test-only, so it reached main behind a green Build: the error
appears solely under `--all-targets`, when the lib TEST target is compiled.
`package`'s "Clippy (host + tray, Windows)" step has failed on every push since
`11217341` landed it (#413), which is the same shape as #408 — a Windows break
the Mac and Linux dev loops cannot see. The commit that introduced it verified
the logic by extracting `stall.rs` into a native scratch crate, so this test was
never compiled for Windows before it merged.

Reproduced and fixed with `scripts/xcheck.sh`, which covers exactly this and
takes seconds on the Mac:

  scripts/xcheck.sh windows clippy   # broken: exit 101, error[E0433] at :2528
  scripts/xcheck.sh windows clippy   # fixed:  exit 0
  scripts/xcheck.sh linux   clippy   # exit 0

Rides in the 0.32.0 release branch because it blocks the Windows installer: the
`package` job fails before "Pack + sign installer", so a tag cut over this main
would publish a release with no Windows host attached.
2026-08-27 19:39:40 +02:00
enricobuehler 92de6a8ce3 chore(release): bump workspace version to 0.32.0
90 commits since v0.31.4 (63 non-merge), cut at db0f4f1e.

**The C ABI steps 25 -> 26.** `punktfunk_connect_opts` closes the
`punktfunk_connect_ex*` ladder: eleven generations each added a field by minting
a new exported symbol plus a 20-something-parameter forwarding shim, and the
replacement takes every option in ONE size-prefixed `PunktfunkConnectOpts`.
Every `ex` keeps its symbol and behaviour, so this is an added surface, not a
changed one. Layout is locked padding-free on both pointer widths (96/68 B) by
const asserts AND by the C harness compiling the same sizes.

The minor bump was already right before the ABI moved: ABR Phase 4 redefines
every control-plane bitrate as the TOTAL WIRE BUDGET rather than the encoder
rate, deliberately without a capability handshake. `include/punktfunk_core.h`
documents the 10-bit SDR ask as "0.32" and says "every pre-0.32 client sets the
two bits together", so the tree had pre-decided the number.

Version table, re-derived at HEAD rather than read off the diff:
  * wire 2 -- unchanged. Three additive, ignorable additions: USER_FLAG_REPEAT,
    CLIENT_CAP_KEEP_HOST_AUDIO, and Welcome::host_caps2 (trailing, absent -> 0)
    carrying HOST_CAP2_REPEAT_MARK
  * C ABI 25 -> 26 (above)
  * crate dirs 27, 39 members, edition 2024, MSRV 1.85 -- unchanged
  * driver proto 6, gamepad channel 3, plugin index 1, host event schema 1,
    gamescope +pfhdr8, SDK 0.1.6 -- all unchanged; pf-driver-proto and
    punktfunk-host/src/events.rs show no diff at all
  * api/openapi.json 0.31.4 -> 0.32.0, content-identical (info.version is the
    whole diff); both copies re-stamped and byte-identical to each other
  * @punktfunk/plugin-kit stays 0.4.4 ON PURPOSE: plugin-kit/src/wire.ts changes
    exactly one line and it is a doc comment in the launch.kind table.
    `launcher_ui` is a bare string validated host-side, so nothing to republish.

The CHANGELOG's `heroic-console` entry was filed under v0.31.4, but ae13b29a
landed AFTER that tag -- so that frozen section described a change v0.31.4 does
not contain. Moved into the v0.32.0 section verbatim.

Seven behavioural breaking changes, none of them a version step, all listed in
CHANGELOG.md: the wire-budget redefinition; FLOOR_KBPS 5000 -> 2000; DSCP
defaults to AUTO (local peers only); GameStream video AND control encryption
default on (both graduated after their own on-glass pass on .173, and
`PUNKTFUNK_GS_ENCRYPT=video` is the new middle rung); PyroWave forces Automatic
bitrate; the console pairing routes move behind the console password (the
security review's critical finding -- a console session cookie alone reached
code execution); deep links auto-dial by stable record id only.

Release notes rewritten to the ISO 24495-1/-5 plain-language rules: sentences
under 30 words, paragraphs under 6 sentences, bold bullet lead-ins, an explicit
overview, a table of contents, and acronyms defined on first use. The plugin's
own text auditor reports 0 findings on the Play notes and 2 on the release
notes, both of which are the words NVIDIA and AMD -- company names, not
acronyms, deliberately left unexpanded.

`cargo audit` stays GREEN: h2 0.4.18 -> 0.4.19 closed RUSTSEC-2026-0258, left
open deliberately at the v0.31.1 cut. Only two allowed `unmaintained` warnings
remain (audiopus_sys, paste).

The Play whatsnew leads with the Android black screen, which is the one fault a
phone or TV user in this cycle could not work around.

Gates on the cut (the Mac): cargo fmt --all --check; cargo metadata --offline;
lock diff versions-only 36/36; cargo test -p punktfunk-core --lib --features
quic 503 passed / 0 failed; the C ABI harness PASSED (abi_version=26); the
regenerated header is not stale; check-docs-drift.sh; check-docs-links.sh; the
Play notes gate 412/500 chars and unique against 15 files; both openapi copies
cmp identical; repo voice scan clean; cargo audit green.
2026-08-27 19:28:32 +02:00
enricobuehler db0f4f1ef8 Merge pull request 'A decoder that refuses to start no longer takes the picture with it' (#419) from worktree-android-decode-start-fallback into main
Reviewed-on: unom/punktfunk#419
2026-08-27 17:05:19 +00:00
enricobuehler 7c0faeebc8 Merge pull request 'A truncated access unit stops aborting the decoder, and a pad's OS identity stops being the client's own numbering' (#418) from worktree-avi-high-fixes into main
Reviewed-on: unom/punktfunk#418
2026-08-27 16:56:39 +00:00
enricobuehler c3e3333c40 fix(client/android): keep ASC alive when the overlay allocation is what start chokes on
Why `configure()` passes and `start()` dies with an AImageReader output on the Mi
TV Stick class of device: configure only records the window — start is where ACodec
dequeues (and thereby gralloc-allocates) every codec output buffer from it, with a
usage that is the OR of our reader's consumer bits and the vendor decoder's private
producer bits. Our reader asked for GPU_SAMPLED_IMAGE | COMPOSER_OVERLAY; the
Amlogic OMX component adds its contiguous-vdec-memory flags. On phones (Codec2,
64-bit, gralloc4) that triple is routine. On a 32-bit OMX-era BSP, an app-side
consumer demanding overlay-scanout + GPU-sampled + vdec-writable in a single
allocation is the exotic ask, and a refusal surfaces exactly as
`start failed: ErrorUnknown` after a clean configure.

So the ladder gets a middle rung that keeps ASC instead of abandoning it: retry
with a reader asking for GPU_SAMPLED_IMAGE alone — the SurfaceTexture shape every
TextureView/WebView video path exercises, the most universally allocatable there
is. SurfaceFlinger then GPU-composites the layer (one 1080p quad — noise), and
everything that makes ASC worth having survives: real latch times, real release
fences, the learned panel period. setBuffer has no overlay requirement, so the
only cost is losing the HWC direct-scanout optimization on devices that were never
going to grant it anyway.

Usage is the only reader axis worth a rung: READER_MAX_IMAGES is not a start-time
factor (consumer-side images allocate lazily during streaming), so a start failure
that survives the gpu-only rung genuinely needs the SurfaceView rungs behind it.

The ladder is now: ASC overlay → ASC gpu-only → SurfaceView (aggressive keys) →
SurfaceView (plain keys), deduped as before so a device that works pays nothing
and each log line names the exact configuration that won or was refused. The
"asc: backend up" line now carries the reader profile too.
2026-08-27 18:56:00 +02:00
enricobuehler 2d0a4270b4 Merge pull request 'The control channel stops reusing a nonce, and the media ports stop trusting whoever knocks first' (#417) from worktree-gamestream-security into main
Reviewed-on: unom/punktfunk#417
2026-08-27 16:44:40 +00:00
enricobuehler 6ef7230ace fix(client/android): a decoder that refused to start took the picture with it
`configure()` succeeding says nothing about `start()` — start is where the codec
negotiates buffers with its output consumer and allocates them, so a decoder that
accepted the format can still refuse the surface it has to render into.

On a Xiaomi Mi TV Stick (Android 11, armeabi-v7a, OMX.amlogic.hevc.decoder.awesome2)
every session logged `start failed: ErrorUnknown` and the decode thread returned, so
not one access unit was ever fed while the pump kept receiving video. The frame queue
filled, the pump jumped to live once per FLUSH_COOLDOWN, and the host read that
perfect 2 s keyframe cadence as a client too slow to sustain the stream. Audio, input
and the library all kept working, so it presented as a permanent black screen with
sound — nine sessions of it in one log, and nothing on the host able to see why.

Give the async loop a bring-up ladder instead of one attempt. A codec that failed
start is in an error state and cannot be reconfigured, so each rung builds a fresh
one and sheds what a start can choke on, most-suspect first: the AImageReader the ASC
presenter renders into (READER_MAX_IMAGES full-resolution PRIVATE COMPOSER_OVERLAY
buffers, which the SurfaceView path does not allocate at all), then the aggressive
low-latency key set. Every downstream branch already keys off `asc.is_some()`, so a
fallen-back session simply runs the SurfaceView presenter that has always been the
API < 29 / ASC-init-failure fallback. Rung 0 is always exactly what the session asked
for, so a device that works pays nothing; the winning rung is logged, so the next
device that needs one names its own culprit instead of leaving us to guess.

The sync loop gets no ladder: it only runs with low-latency mode off, which is
already the conservative key set, and it renders straight into the SurfaceView — both
axes are already shed, and there is no simpler configuration to fall back to. It gets
the diagnosis in its error line instead, since the session stays up around the
failure there too.

Also lifts the HDR static-info fetch above the ladder so a retry never pays its
250 ms wait again, and extracts the async-notify callback registration, which each
rung's fresh codec now needs.
2026-08-27 18:44:37 +02:00
enricobuehler 29bfbcb950 Merge pull request 'A stored bundle is a success — Android stops reporting 201 as a failed log upload' (#416) from worktree-android-sendlogs-201 into main
Reviewed-on: unom/punktfunk#416
2026-08-27 16:43:34 +00:00
enricobuehler f4f318b8f4 Merge pull request 'Five places where one fact lived twice now state it once (maintainability sweep)' (#415) from worktree-refactor-sweep into main
Reviewed-on: unom/punktfunk#415
2026-08-27 16:42:52 +00:00
enricobuehler ec621882f0 fix(android): a stored bundle is a success — 201 stops reading as a failure
Field reports of "201" when sending logs to the host: the upload was working
the whole time, and the client was calling it an error.

`POST /api/v1/client-logs` answers **201 Created** — it is a route that STORES
a bundle, and it has said so since the feature landed (`mgmt/client_logs.rs`,
where CREATED is both the returned status and the documented one). The Android
uploader tested `resp.code == 200` and treated everything else as a failure, so
the user got "Couldn't send logs — host answered HTTP 201" while their bundle
was already sitting on the host's web console Logs page. Worse than a cosmetic
lie: it invites a retry, and the store keeps only 5 bundles per device, so a
few retries evict the reporter's own earlier logs.

The check now uses OkHttp's `isSuccessful` (200..299) rather than a
hand-written code, so there is no second list of numbers to fall out of sync
with the host.

One line covers both Android shells: since the send-logs work spread the action
to every UI, the Skia console and the touch home both route through
`SendLogs.toHost`, which is the only place the status is judged.

The other legs were already correct and are untouched: Apple matches
`case 200, 201` (`LibraryClient.swift`), and the desktop ureq path treats any
2xx as `Ok` — only 4xx/5xx become `Error::StatusCode`. A sweep of the remaining
strict-200 comparisons in the clients found only GET art/status fetches, where
200 really is the sole success.

Verified with `./gradlew :app:compileDebugKotlin` (task executed, not cached).
2026-08-27 18:33:44 +02:00
enricobuehler 86fbd8121b fix(host/input): a pad's OS identity is host-wide, not the client's wire index
Every OS-level name a virtual pad needs is derived from a pad index and nothing
else: the `Global\pfxusb-boot-<i>` / `Global\pfds-boot-<i>` bootstrap mailboxes, the
`SwDeviceCreate` instance ids (`pf_xusb_<i>`, `pf_pad_<i>`, `pf_ds4_<i>`,
`pf_xbox_<i>`), and on Linux the DualSense pairing MAC, the Deck serial and the
Switch MAC — the last three documented as needing to be unique per pad, because
hid-playstation adopts the MAC as the HID `uniq` and SDL/Steam dedup by that serial.

The host serves up to DEFAULT_MAX_CONCURRENT sessions of the same desktop, each with
its own input thread and its own router, and every client numbers its first
controller wire pad 0. So two paired clients each holding a controller collide on all
of them. On Windows the second session's `Shm::create_named` sees ERROR_ALREADY_EXISTS
on all five retries and never gets a pad for the whole session — and the create-failure
hint tells the operator to restart the service, which would kill both sessions, when no
other process is involved at all. On Linux nothing errors: both mint the same DualSense
MAC and SDL merges the two pads into one controller.

The wire index is a session's own numbering and cannot be an OS identity. New
`pf_inject::pad_pool` makes the OS slot host-wide — claimed on a pad's first present
frame, released when it goes away, freed wholesale when the session drops — and `Pads`
translates once on the way in. Because only the NUMBER changes and not the name format,
the drivers (which read the index back out of `pszDeviceLocation`) need no change.

Slots are claimed lazily rather than as fixed per-session windows, so a single session
still reaches all MAX_PADS pads; two sessions share the range. An exhausted host now
declines with an honest line instead of retrying against a name it can never win.

Feedback reverses the same map: a backend tags rumble and rich HID output with the OS
slot it created the device under, so `Pads::pump` maps it back to the client's wire
index — otherwise the fix would have delivered one client's rumble to another's pad.
`HidOutput::pad`/`with_pad` keep that translation in one place, so a seventh variant
cannot silently forget it.

Verified: pf-inject 40 tests (8 new, covering the collision, single-session reach,
release, drop, exhaustion, mask and reverse-map), punktfunk-core 502 with `--features
quic`, and `cargo check -p punktfunk-host` clean on linux/amd64 in punktfunk-rust-ci —
punktfunk-host does not build on macOS, so that check is the only compile signal for
this file and it was not skipped.
2026-08-27 18:30:07 +02:00
enricobuehler 6092f80df2 The house DRM-node knob is parsed once, in the crate that owns GPU choice
PUNKTFUNK_RENDER_NODE had three independent readers disagreeing on trim
and empty-string handling (pf-gpu: no trim; pyrowave: trim+filter; the
remote worker handshake: raw .ok(), so an empty or padded value crossed
the process boundary verbatim). pf_gpu::render_node_env() is now the one
parse; linux_render_node layers the console's manual GPU preference on
top, and the PyroWave sites keep reading the env-only form deliberately —
their device-selection oracle rules forbid the preference layer.
2026-08-27 18:30:07 +02:00
enricobuehler 92f80bdfde Bring-up and the compositor retarget derive the cursor-composite pair once
The two sites had drifted — the class of defect virtual_stream's size
invites. Bring-up keyed gamescope_composite on the compositor alone; the
capture-loss retarget read plan.gamescope_cursor, which also folds in the
gamescope_composites_cursor() capability (a gamescope that paints the
pointer into its own node). On such a node, bring-up planned a host
composite the XFixes reader never feeds. composite_plan() is now THE
derivation for both, settle_portal_cursor's discipline applied to the
pair session_plan.rs already documents as must-agree.
2026-08-27 18:29:58 +02:00
enricobuehler c53dd15313 The control task's 31 positional arguments become a named-field Task
Five mpsc senders and six receivers in a row, two of them both carrying
bare u32s (retarget_rx / gap_rx) — one silent transposition at the spawn
site away from a runtime puzzle. control::run now takes control::Task,
destructured at the top into the same-named locals, so the 500-line body
is byte-for-byte unchanged and the spawn site reads as named fields.
Drops one of the workspace's 127 too_many_arguments allows.
2026-08-27 18:29:44 +02:00
enricobuehler b61566e6c6 serverinfo stops advertising HEVC capacity it may not have
`MaxLumaPixelsHEVC` was the constant `1869449984` in every document, including
the ones whose `ServerCodecModeSupport` had just dropped HEVC — a GPU-less host
encodes H.264 and nothing else, and said so in the mask while still quoting a
4K60 HEVC ceiling two lines above it. The code's own comment flagged the
contradiction and left it, on the grounds that Moonlight gates on the mask.

That reasoning is right, which is exactly why this is worth closing rather than
keeping: the field is harmless only for as long as every client keeps ignoring
it, and nothing on our side guarantees that. It now follows the mask, with `0` —
the field's own way of saying no HEVC capacity — when HEVC is not offered.

Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95.
2026-08-27 18:26:20 +02:00
enricobuehler d3616aaef9 Four security questions get answers instead of follow-up notes
None of these change behaviour. All four were carrying comments that read as
unfinished work, and the work turned out to be establishing what is true.

**TLS accepts any client certificate — and should.** The verifier's comment
called pinning "a hardening follow-up". It is not one. A TLS handshake completes
before the request line is parsed, so "pin the post-pair routes, accept-any on
the pairing routes" cannot be expressed there at all — it would take a second
listener on a second port, and the protocol fixes the ports. Some HTTPS traffic
must come from unpaired peers anyway: `/serverinfo` answers `PairStatus=0`
precisely so a client can discover it needs to pair, and the management API goes
further and admits certless browsers. And deferring costs nothing: the
`CertificateVerify` signature IS checked, so a peer reaching a handler has proved
possession of its certificate's private key, and `peer_is_paired` pins the
SHA-256 of that same certificate before any state-changing work. Every route but
`/serverinfo` goes through it. Rejecting with an HTTP error rather than a TLS
alert is a difference in *when*, not in what is proven. The comment now says
that, including the thing that WOULD be a hole — accepting the certificate
without checking the signature, which is why the legacy-Moonlight fallback
re-verifies rather than waving old certificates through.

**The PIN's 4-digit space is not the exposure.** 10⁴ invites the brute-force
question, and the answer is that nothing can brute-force it: submission is the
bearer-authenticated management API and nowhere else, so a network peer has no
oracle to hammer, and a per-attempt cap would bound the operator's typos rather
than an attacker. A wrong PIN costs a fresh client handshake *and* a fresh
operator submission — not a loop anyone automates from the network. The real
residual is **capture**, not guessing: the PIN slot is bound to no particular
handshake, so a peer parked at the right moment can take the PIN typed for
someone else. That is already narrowed twice (an ambiguous submit is refused, an
unconsumed PIN expires) and its full fix is to key the gate by `uniqueid` —
which also needs the management API to name *which* device is asking, so the
operator answers a named prompt. Written down so the next reader audits the race
rather than re-deriving the brute-force answer.

**Audio's unauthenticated CBC cannot be negotiated away.** The instinct is to
reach for `SS_ENC_AUDIO` (0x04) as the authenticated answer. Per the sanctioned
wire reference, `SS_ENC_AUDIO` *selects exactly the mode we already implement*:
"if SS_ENC_AUDIO: AES-128-CBC encrypt the PKCS7-padded Opus frame", noted there
as "CBC, not GCM. No auth tag appended (unlike video/control GCM)", negotiated
through `x-nv-general.featureFlags` bit 0x20 rather than the `encryptionSupported`
mask. So GameStream has no authenticated audio mode to advertise: offering the
flag would change nothing on the wire, and adding a tag would be a private
extension no client can decode. The malleability is real and it is
protocol-inherent, in the same family as pairing over plain HTTP. A session that
needs authenticated audio needs the native plane, whose audio is AES-GCM. This
moves the item from "blocked on a layout we could not find" to "answered" — the
layout was not missing, the premise was.

**`encoderCscMode` was warning about something it could not act on.** The note
claimed Moonlight renders from the requested value rather than the bitstream VUI,
so any mismatch shifts colours. The wire reference does not say that — it lists
the key among those a host parses, and nothing more — and we emit an explicit,
correct VUI that a VUI-driven renderer would follow. Worse, the common case is
unactionable by construction: this session's own on-glass run shows a stock
client sending `csc=3` (Rec709 **full**) while negotiating HDR, and HDR10 *is*
BT.2020 PQ, so the request and the mode contradict each other and the mode wins.
The old code warned on every HDR session about that. It now says so at debug, and
keeps a warning only for SDR, where the request could in principle be met —
honestly labelled unverified.

Also recorded: honouring it is not "just plumbing". `videoFullRangeFlag` is
hardcoded to 0 in every encoder backend with the capture-side CSC fixed to match,
so an SDR client asking for full range needs a per-session colour request
threaded from RTSP through the capture CSC into each backend's VUI — code the
native plane shares and currently gets right. That is a real piece of work with a
real regression surface, and it should be scoped as one rather than as a logging
follow-up.

Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95.
2026-08-27 18:18:17 +02:00
enricobuehler 9c164aa614 The connect ladder gets a last rung that never mints another symbol (ABI v26)
Eleven generations of punktfunk_connect_ex* each added a field by minting a
new exported symbol plus a 20-something-parameter forwarding shim (ex11 over
ex10: two fields). punktfunk_connect_opts takes every option in ONE
size-prefixed PunktfunkConnectOpts — PunktfunkConfig's struct_size guard,
with the growth direction added: an older, shorter caller gets its tail
defaulted to unspecified/auto instead of misread. Layout is locked
padding-free on both pointer widths (96/68 B) by const asserts AND by the C
harness compiling the same sizes, so an appended field can never land in
bytes an older sizeof already covered. Every ex keeps its symbol and
behaviour; the chain is closed in the embedding guide (whose ladder table
had stalled at ex7 — the maintenance tax on display).

Also: the cargo-test C harness now actually links on an Apple Silicon dev
box (-L/opt/homebrew/lib was missing) and always builds the staticlib WITH
quic — a featureless .a left by an earlier plain build was silently reused.
2026-08-27 18:16:10 +02:00
enricobuehler 559b185247 Three hand-copies of the same env knob parse once (encoder IR/LTR policy)
QSV's copies had already diverged twice in ways the file itself confesses
(the dropped trim() that left LTR enabled on Intel, the IR period knob that
silently did nothing) — and PUNKTFUNK_LTR_FORCE_AT=0 still behaved
differently per vendor. enc/policy.rs now parses each knob once, rfi.rs's
sibling; tuned per-vendor defaults (QSV 1/4 s vs AMF 1/2 s LTR cadence) and
API-bound clamps deliberately stay with their backend.
2026-08-27 18:15:57 +02:00
enricobuehler 5317c583ab fix(bitstream/av1): an over-declared OBU is a parse error, not a decode-thread panic
`obu_size` is a leb128 read straight out of the stream — bounded only by `u32::MAX`,
and tied to nothing about how many bytes are actually present. `read_obu` then built
the OBU with an unchecked `&data[start_offset..start_offset + obu_size]`, so any
access unit whose last OBU declared more payload than remained panicked with
`range end index .. out of range for slice of length ..`. That is a bounds check
rather than arithmetic, so it panics in release too, and it aborts whichever thread
is decoding.

It reaches every native AV1 rung: pf-vkdecode, pf-dxvadec and pf-vaadec are all
re-exports of `pf_bitstream::av1::Av1Planner`, whose `plan_au` hands raw access-unit
bytes straight to this function. `PUNKTFUNK_AU_FAULT=truncate` produces the shape,
and so does any AU delivered short over the wire.

This was a hole in an otherwise consistent posture, not a missing idea: `plan_au`
degrades every other malformation to `TruncatedAu`/`Parse`, and pf-vkdecode already
re-validates `obu.end > au.len()` a layer up. Bound it once, where all three rungs
route through. The checked end is reused for `bytes_used`, so the slice and the
advance can no longer disagree.

Both tests fail without the guard — the parser one reproduces the original panic
verbatim (`range end index 10600 out of range for slice of length 5293`).

Recorded as PROVENANCE.md deviation 14. Not filed upstream.
2026-08-27 18:06:31 +02:00
enricobuehler a79343784c Control encryption is on by default — a real client negotiated it unprompted
The previous commit shipped `SS_ENC_CONTROL_V2` dark, on the grounds that the
wire had not yet met a real client here. It has now, twice, on `.173` (Win11,
RTX 4090) against Moonlight on macOS at 2560x1440@240 HEVC Main10 HDR.

| Leg | Result |
|---|---|
| `control` (SUPPORTED, not requested) | **the client turns it on by itself** — video and audio flow, hardware decode |
| `require` (both bits REQUESTED) | negotiated and streamed the same way; the lever is not broken |

The line that settles it is the host's own scheme detector, which reports what
it actually locked onto rather than what was negotiated:

```
control: GCM scheme locked in
  scheme=Scheme { key_rev: false, nonce: V2 { seq_be: false, marker: [67, 67] },
                  tag_first: true, aad: None }
```

`[67, 67]` is `b"CC"` — the client→host V2 marker — after which the host sealed
its HDR-mode cue under the flipped `b"HC"` and the client acted on it. Both
directions on distinct nonces, on hardware. That is the defect from the previous
commit's note, observed closed rather than argued closed.

So `Supported` — the default — now carries both bits, exactly as WP7's video
encryption graduated after its own glass pass. What used to be the `control`
opt-in becomes `PUNKTFUNK_GS_ENCRYPT=video`, the way *out*: it keeps video
encryption and drops only the control offer. That granularity is worth its three
lines rather than being YAGNI — this plane serves a spread of client builds and
exactly one has been tested against the V2 offer, so the alternative rollback
(`=0`) would throw away video encryption to fix a control-channel problem.

**The deferral that turned out not to exist.** The worry was that control-v2
would drag encrypted RTSP in with it, forcing a `corever` threshold nobody has
the value for. Both legs kept RTSP plaintext — the client asked for
`rtsp://192.168.1.173:48010` and stayed there — which confirms the framing is
chosen by the launch URL scheme, not by the DESCRIBE flag. The sealed-RTSP
reader is in and unit-tested, but it has not been exercised on the wire; it will
be the day a client opens a connection with the type bit set, and it is written
to answer in kind when that happens.

**The other thing glass settled** is the previous commit's open question about
the ping payload's encoding. Both media planes logged `client endpoint learned
(ping payload verified)` on the first datagram, in both legs — so the payload
crosses in a form `ping_matches` accepts, the two-second fallback never fired,
and that check can be tightened from "prefer" to "require" whenever we want it.

Docs follow: `moonlight.md` no longer says the control channel "uses the older
GameStream scheme", which stopped being true for a stock client with this
commit, and `configuration.md` documents the new `video` value.

Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95. One `send_pacing` failure on the
first Linux run was a timing flake under load (0.36 s vs the usual 0.06 s) and
passed 14/14 on re-run — the suite is known for it. `.173` was restored
afterwards: original binary back byte-for-byte by size and timestamp, no test
lines in `host.env`, service running, listeners up, and the build tree this
session created removed.
2026-08-27 18:06:16 +02:00
enricobuehler f4a2698711 The control stream stops sharing one nonce space with the client
The legacy GameStream control scheme builds its AES-GCM nonce out of the
sender's own sequence number and nothing else. There is no direction byte, so
the host's messages — rumble, the HDR-mode cue — and the client's input share a
single (key, nonce) space and collide the moment their two independent counters
cross. That is the one catastrophic GCM failure, and `control.rs` has carried a
note saying so, ending "the real fix is V2 control-encryption negotiation".

This is that fix, and most of it turned out to be already written. The scheme
detector has understood `NonceKind::V2` — `seq` in `[0..4]`, `b"CC"` at
`[10..12]` — since it was built, and `encrypt_control` already flips the marker
to `b"HC"` for host-originated messages. What was missing was the one thing that
makes a client choose that scheme: the advertisement. So the control plane needs
no code change at all here; it needs `SS_ENC_CONTROL_V2` in
`encryptionSupported`, and it detects the rest from the first packet that
authenticates.

Enabling V2 also lets the client seal RTSP itself, which is the part that was
deferred, and the reason it was deferred dissolves on inspection. The worry was
needing to know `corever` — the field that decides whether a session speaks
sealed RTSP — whose threshold the sanctioned reference names but does not value,
and guessing it would break every session it guessed wrong about. But the two
framings are **self-distinguishing**: a sealed message opens with
`typeAndLength`, whose MSB is `ENCRYPTED_MESSAGE_TYPE_BIT`, and a plaintext one
opens with an ASCII method name, always below 0x80. So there is nothing to
negotiate and no threshold to guess — the connection is read in whichever
framing it arrived in and answered in the same one, the same "detect it from the
wire" posture the control stream already takes. `corever` never has to be
consulted, which also means it never has to be guessed.

Two details worth their own lines:

**The host's RTSP sequence counter is process-global and never resets.**
GameStream RTSP is one message per TCP connection, so a per-connection counter
would restart at zero for every one of a session's seven messages and reuse
(key, nonce) six times over — reintroducing, in the fix, exactly the defect
being fixed. Same rule WP7 established for the video counter, and same reasoning.

**The frame's length field is bounded before it is believed.** It is
attacker-controlled and arrives before a single byte has authenticated, so it is
checked against the same budget the plaintext path uses rather than trusted
enough to reserve against.

Shipped dark, deliberately: `PUNKTFUNK_GS_ENCRYPT=control` opts in, the default
advertisement is unchanged, and `require` (the on-glass lever) now covers both
bits. This is the posture WP7 shipped video encryption in — dark until glass
confirmed a real client negotiated it — and control-v2 has not yet met a real
client here. Flipping the default is a one-line change once it has.

The two comments claiming we "advertise no encryption" are corrected while I am
here; they have been wrong since WP7 advertised `SS_ENC_VIDEO`, right in their
conclusion and wrong in their reason.

Gate: Linux container fmt + clippy `--all-targets -D warnings` + 14/14 pacing +
96/96 gamestream (93 before); Windows `.133` clippy on the full
`nvenc,amf-qsv,qsv` matrix + 14/14 + 95/95 (92 before). The three new tests cover
the receive path as well as the send path, and were confirmed non-vacuous by
mutating the nonce's direction byte to a constant — both directional assertions
fail, which is the security property doing its job.
2026-08-27 17:49:39 +02:00
enricobuehler eb8117b551 The media ports learn their endpoint from the client that can prove it
Both media planes learned where to send by taking the first UDP datagram that
arrived from the launch owner's address and never looking inside it. The ping
payload the protocol exists to carry — handed to the client in the SETUP
response, echoed back as its first datagram — was the fixed constant
`0011223344556677` for every session on every host, and nothing ever compared
it against what arrived. Source-IP binding was the whole guard, so anything
that could send from (or spoof) that address won the endpoint simply by being
first, and was handed the stream.

So the payload becomes a per-session secret: eight random bytes minted at
`/launch`, re-minted at `/resume`, advertised hex-encoded in SETUP, and checked
before an endpoint is adopted. Both planes now go through one
`learn_client_endpoint`, because they had drifted into two byte-identical loops
and a rule about who owns a stream should exist once.

Minting alone would have proved nothing, though, and this is the part worth
pausing on: SETUP was **unauthenticated** while its siblings ANNOUNCE and PLAY
were not. Any peer that could reach 48010 could simply ask for the payload the
media planes were about to verify, and walk the check. So SETUP is gated on
`authorized_launch` like the other two — a secret handed to whoever asks is not
a secret, and that hole, not the constant, was the reason the old check would
have been theatre.

The check **prefers** a matching datagram rather than **requiring** one, and
that is deliberate rather than timid. The sanctioned wire reference says the
client echoes the payload and that modern clients wrap it in an `SS_PING`
carrying a sequence number, but it gives neither that structure's layout nor
whether the payload crosses as the header's ASCII or as its decoded bytes.
`ping_matches` accepts every shape those unknowns allow — either encoding, with
trailing bytes — yet a hard gate resting on a layout nobody has confirmed would
black-screen every session it guessed wrong about, and compatibility is this
plane's entire reason to exist. An unverified datagram is therefore held as a
fallback, adopted only if two seconds pass with nothing better, and logged with
the bytes that did arrive. One real session settles the encoding; then the
fallback can go and this becomes the hard gate it wants to be.

What this closes: an off-path attacker who cannot read the plaintext RTSP
exchange can no longer guess its way onto a media port. What it does not close:
an on-path observer, who can still read the payload out of SETUP — that one
needs `SS_ENC_CONTROL_V2`, and the two changes are worth more together than
either is alone.

Constant-time comparison throughout (`crypto::ct_eq`), so the expected value
can't be probed a byte at a time.

Gate: Linux container fmt + clippy `--all-targets -D warnings` + 14/14 pacing +
93/93 gamestream (90 before). The three new tests were confirmed non-vacuous by
mutating `ping_matches` to return `true` and watching the negative case fail.
2026-08-27 17:32:54 +02:00
enricobuehler d2286da955 Merge pull request 'The Moonlight plane stops paying for protection it doesn't need, starts listening to the client, and encrypts its video' (#414) from worktree-gamestream-wp1 into main
Reviewed-on: unom/punktfunk#414
2026-08-27 15:05:12 +00:00
enricobuehler ac14b6554e feat(gamestream): video encryption is on by default — a real Moonlight client negotiates and decodes it
Review of 47d9a7d2 plus the on-glass pass it was waiting for, run 2026-08-27 against
.173 (Win11, RTX 4090) from Moonlight on macOS. Four legs, all pass:

  1. encryption off      -> streams, encrypt_video: false (no regression)
  2. =1 (supported)      -> the CLIENT OPTS IN BY ITSELF, decodes, 0 errors
  3. =require            -> negotiated, decoded in hardware, 0 errors
  4. =require + 5% loss  -> 27 s, 8 keyframes decoded, ZERO IDR re-requests

Leg 2 is why the default moves: I had argued a client might only enable video
encryption on WAN/relay links — which is why the `require` lever exists at all — and
it opted in on a LAN unprompted. Default-on therefore delivers encryption in
practice rather than being a no-op offer.

Leg 4 is the one that mattered most: at 5 % injected wire loss the client never asked
for a single keyframe, so FEC recovered every dropped SEALED shard. That is the
FEC-then-encrypt ordering proven on hardware, not just in the unit test.

Two independent confirmations of the wire format fell out of the run:
  * the client negotiated packet_size 1360 where the plaintext leg used 1392 — it
    subtracted sizeof(ENC_VIDEO_HEADER) exactly as the reference says, so the on-wire
    datagram is 1408 either way and still fits the MTU it sized for;
  * FFmpeg parsed nal_unit_type 19 (IDR_W_RADL) and reported 'Decoded frame with POC
    0/0' on a Main 10 VideoToolbox path — a real decoder reading real HEVC out of the
    decrypted, FEC-reassembled payload.

Default is now Supported; PUNKTFUNK_GS_ENCRYPT=0 is the escape hatch back to the
plaintext wire, `require` stays as the test lever that forces the negotiation.

Review fixes in the same change (found reading 47d9a7d2 back, before the test):
- A failed seal pushed the CLEARED buffer, putting a 0-byte datagram on the wire.
  Unreachable in practice (GCM only refuses absurd lengths) but the wrong failure
  mode: the shard is now dropped, which is what FEC is there for. seal_shard returns
  bool and — per clippy, correctly — takes &mut [u8] now that it never clears.
- `off` and `key` derive separately from self.enc_key; they cannot desync today, but
  if they ever did, sealing would encrypt from offset 32 INTO the shard body and
  corrupt every packet with no error anywhere. A debug_assert states the invariant.
- Stale rtsp module header (it still said plaintext-only, P1.5).

Docs follow the behaviour: moonlight.md/clients.md now say video is encrypted, and
the 'legacy control encryption is weaker' caveat is corrected — it was overstating
things now that video and audio are both sealed; what remains true is that pairing is
plain HTTP and the control channel uses the older GameStream scheme.

Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 90/90; Windows .133 clippy nvenc,amf-qsv,qsv clean +
14/14 + 89/89; check-docs-drift.sh + check-docs-links.sh clean. .173 restored to its
original binary and host.env, build tree removed.
2026-08-27 16:48:14 +02:00
enricobuehler 47d9a7d2fa feat(gamestream): video encryption exists on the wire, behind a knob until glass confirms it
GS competitive program WP7 — the largest remaining wire divergence from a Sunshine-class
host, and the last item the docs' "weaker than the native protocol" caveat rested on.

- RTSP DESCRIBE advertises SS_ENC_VIDEO (0x02) as encryptionSupported and NEVER as
  encryptionRequested: requiring encryption would refuse every client that doesn't do
  it. ANNOUNCE's x-ss-general.encryptionEnabled echo decides the session, and is
  honored only for a bit the host actually offered.

- Each finished datagram is AES-128-GCM-sealed under the /launch rikey and shipped as
  [iv 12][frameNumber u32 LE][tag 16] || ciphertext(blocksize) — the ENC_VIDEO_HEADER
  layout, whose 32 bytes sit OUTSIDE the FEC blocksize (the client already subtracted
  them from the packetSize it negotiated, so the datagram still fits its MTU).

- The order is FEC first, THEN encrypt per shard. That is the load-bearing property,
  and it has its own test: the client decrypts what it received and runs RS recovery
  over those plaintexts, so parity computed over ciphertext would recover nothing.
  Sealing is in-place into a buffer that reserved the prefix, so the pooled
  no-allocation path (WP1.3) survives; parity, which encode_into sizes exactly, costs
  one memcpy on the ~20 % of packets that are parity.

- The GCM nonce counter is PROCESS-global and monotonic, never reset. (key, nonce)
  reuse is the one catastrophic GCM failure, and a session-scoped counter would repeat
  the moment a KEYLESS /resume — which WP3 defines as keeping the current keys —
  started a fresh packetizer on the same rikey.

DEFAULT OFF, opt in with PUNKTFUNK_GS_ENCRYPT=1. This is the compat plane's video hot
path and a wire mistake there is a black screen for any client that opts in; I cannot
run a stock Moonlight client in this environment, so it ships dark and the WP0.3
on-glass pass flips the default. SS_ENC_CONTROL_V2 and SS_ENC_AUDIO stay unoffered:
control-v2 also re-frames RTSP itself, and the audio-GCM layout is not in the
sanctioned wire reference.

Also WP8's docs pass, now that the claims are false in the user's favour:
moonlight.md said the GameStream path "doesn't use the native protocol's
FEC/encryption extensions" — Moonlight-compatible FEC has shipped for months and the
host now adapts it to reported loss. Rewrote that (and the clients.md twin) to say
what Moonlight actually does and doesn't get, documented the bitrate as the wire
budget it became in WP2.1, and documented the three new knobs (PUNKTFUNK_FRAME_DRIVEN,
PUNKTFUNK_GS_ADAPT, PUNKTFUNK_GS_ENCRYPT) — check-docs-drift.sh gates that.

⚠ The drift gate also demanded PUNKTFUNK_IDD_ADAPTIVE be pruned from the undocumented
baseline: it is documented in configuration.md but was never pruned, so that ratchet is
red on main independently of this branch. Pruned here since the gate refuses to pass
otherwise.

Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 89/89 (new: encrypted round-trip incl. tamper + wrong
key + IV uniqueness, and RS recovery THROUGH encryption); Windows .133 clippy
nvenc,amf-qsv,qsv clean + 14/14 + 88/88; check-docs-drift.sh and check-docs-links.sh
both clean.
2026-08-27 16:15:20 +02:00
enricobuehler 247832014a feat(gamestream): the compat plane joins the shared session plan, ships the real HDR grade, and stops streaming unprotected stereo
GS competitive program WP5 (the media items implementable without new wire research —
4:4:4 and encoderCscMode stay follow-ups: the 4:4:4 SCM extension bit values are not in
the sanctioned wire reference, and CscMode honor is an encoder-wide colorspace plumb).

- WP5.1 the capture format comes from the shared SessionPlan (gs_session_plan: 4:2:0,
  depth 10 only with HDR, no cursor-forward — this plane has no client cursor channel)
  instead of the hand-hardcoded OutputFormat::resolve(hdr, gpu). One resolver for both
  planes, and the visible win: a gamescope GameStream session (cursor blend off there)
  now resolves nv12_native, so the producer's NV12 feeds Vulkan Video directly and the
  per-frame RGB→NV12 CSC the native plane already skips is skipped here too. Also the
  structural prerequisite for 4:4:4 when its advertisement bits are researched.

- WP5.4 the encoder gets the source's REAL HDR grade every frame (set_hdr_meta from the
  capturer, exactly the native loop's shape): an HDR backend embeds it as in-band
  mastering/CLL SEI on keyframes — the channel a stock Moonlight decoder tone-maps
  from. This plane never called it, so an HDR GameStream session shipped no grade at
  all; the 0x010e control cue only flips the display mode and carries generic fallback
  metadata.

- WP5.5 RS(4,2) audio FEC on every layout — stereo included. The parity math is
  layout-agnostic (shards are opaque encrypted packets) and a stock client runs the
  same fixed RS(4,2) recovery regardless of channel count; the old channels>2 gate was
  bring-up caution that left the MOST COMMON configuration with zero audio loss
  protection.

Gates: Linux container fmt + clippy --all-targets -D warnings + send_pacing 14/14 +
gamestream 87/87; Windows .133 clippy nvenc,amf-qsv,qsv clean + 14/14 + 86/86.
On-glass owed with the WP0.3 pass: gamescope NV12 path on .41, HDR SEI in a client
bitstream dump, stereo FEC under netem loss.
2026-08-27 15:58:47 +02:00
enricobuehler 2031066539 fix(gamestream): the MAC Moonlight wakes is finally the host's own
GS competitive program WP6.1. serverinfo's <mac> was a fake constant
(01:02:03:04:05:06) over HTTPS — and Moonlight PERSISTS that field as the target for
its Wake-on-LAN, so every client-side wake against this host was a silent no-op. It
now carries the real routed-NIC MAC via the same crate::wol::wake_macs the native
plane's mDNS advert uses (routed NIC first, lowercase colon-hex — the wire reference's
documented shape: real MAC on HTTPS, zeros on plain HTTP).

Cached on first SUCCESS only: /serverinfo is polled, so per-poll NIC enumeration is
waste — but a cold-booted host may not have a routable address yet (the #366 boot-race
lesson), so a failed read retries on the next poll instead of latching zeros forever.
Plain HTTP keeps hiding the MAC.

Gates: Linux container fmt + clippy -D warnings + send_pacing 14/14 + gamestream
87/87 (new pin: never the fake MAC, hidden over HTTP); Windows .133 clippy
nvenc,amf-qsv,qsv clean + 14/14 + 86/86.
2026-08-27 15:48:43 +02:00
enricobuehler 31eb91e2b1 feat(gamestream): the host tells its owner the truth, and a resume actually resumes
GS competitive program WP3 (design/gamestream-competitive-program.md). Three fixes
that together make Moonlight's Resume/Quit UX exist against this host for the first
time — serverinfo hard-coded state=FREE/currentgame=0, so no stock client ever showed
Resume or Quit, and /resume read no query params at all, so even a reached resume ran
on stale crypto keys.

- serverinfo now reports the live session's appid + SUNSHINE_SERVER_BUSY — to the
  session OWNER's pinned cert ONLY. Owner-scoped on purpose (owner_current_game, pure
  + tested): a non-owner shown the truth would route same-app taps into the owner-only
  /resume and /cancel and lose the reject/join/steal admission it gets via /launch
  today; and a busy signal over plain HTTP would leak what's running to the LAN.
  Advertisement fails CLOSED on unknown fingerprints (unlike the control gate, which
  deliberately fails open).

- /resume parses rikey/rikeyid and RE-KEYS the live session (a resuming Moonlight
  mints fresh keys; the control GCM and audio CBC derive from them — the old keys made
  every post-resume control packet undecryptable). Malformed rikey refuses the resume;
  keyless keeps the current keys. The media/RTSP source-IP binds re-anchor to the
  address the client resumes from, so a device that moved networks can come back.

- /resume RESTARTS the media planes for the new connection. A PLAY that found
  'streaming' still true took its already-running branch: the old threads kept
  streaming at the vanished endpoint and the resumed client got no media. The handler
  clears the run flags and WAITS (bounded, 2 s) for the old threads' FULL exit — each
  media thread now bumps AppState::media_exited as its last act, after its teardown
  (capturer re-pool, lease guard, events) has run — so the successor threads never
  race the predecessor over the pooled capturer and the old exit path cannot stomp
  the new session's flags. The launch surviving that wait is re-keyed; one cleared by
  the old threads' client-unreachable teardown falls back to a clean /launch. The
  ENet control plane already handles the fast-reconnect half (a new Connect replaces
  the tracked peer; the old peer's late timeout is gated off it).

Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 86/86 (new: owner-scoped currentgame matrix, resume
re-key round-trip incl. malformed/keyless arms, busy-state XML pin); Windows .133
clippy nvenc,amf-qsv,qsv clean + 14/14 + 85/85. On-glass resume with a stock
Moonlight client owed (WP0.3 pass).
2026-08-27 15:45:11 +02:00
enricobuehler 7df36a1b3d feat(gamestream): the host finally listens when the client reports loss
GS competitive program WP2.2-2.4 (design/gamestream-competitive-program.md). The
Moonlight plane's only in-stream quality signal — the periodic 0x0201 loss-stats
report — used to fall through the control decoder and be silently dropped; the host
was blind to client-observed loss and its only recovery was serving IDR round-trips.
No Sunshine-class host does more than log this signal. Now:

- WP2.2 the control stream decodes 0x0201 (LE i32s: loss count, window ms,
  last-good frame — the verified IDX_LOSS_STATS reading, apollo-comparison #94) into
  cumulative per-session counters on AppState (GsLossStats), read as window deltas.

- WP2.3 adaptive FEC: the parity percent climbs fast under loss (+max(5, pct/2) per
  lossy window, cap 50) and decays slowly on clean windows (-5 per 8, floored at the
  configured base). Applied live through the packetizer between frames — safe because
  block geometry is derived per frame and the client reads each block's parity count
  from the per-packet fecInfo wire percent.

- WP2.4 bitrate de-rating: SUSTAINED loss (2+ consecutive lossy windows) de-rates the
  wire budget x0.85 per window, floored at max(1/4 of the negotiated rate, 5 Mbps);
  clean windows climb it back at 1/20 of the negotiated rate per 4 — division of
  labour as on the native plane: parity answers random/burst loss, fewer bits answer
  congestion. The client's negotiated number stays the hard ceiling.

- The WP2.1 invariant is kept live: every step re-derives the ENCODER rate under
  (budget, percent) and retargets it in place (Encoder::reconfigure_bitrate); the send
  pacer's rate follows through an atomic. An encoder that refuses the in-place
  retarget (software paths) turns adaptation OFF for the session — raising parity
  over a frozen encoder rate would push the wire back over budget. The stats sample
  reports the LIVE budget, so the console shows what the host actually targets.

PUNKTFUNK_GS_ADAPT=0 pins both levers at their configured values (the A/B lever).
Constants are deliberately conservative first values — the WP0.3 netem matrix tunes
them. Also: the stale module/status doc headers the completeness inventory flagged.

Gates: Linux container fmt + clippy --all-targets -D warnings (non-vacuous) +
send_pacing 14/14 + gamestream 83/83 (incl. the two new adaptation state-machine
tests); Windows .133 clippy with nvenc,amf-qsv,qsv clean + send_pacing 14/14 +
gamestream 82/82 (amf-qsv,qsv — the box has no NVENC import lib for test linking).
On-glass netem verification (WP0.3) owed.
2026-08-27 15:22:52 +02:00
enricobuehler ab5e642f84 perf(gamestream): the compat plane stops paying for congestion protection it doesn't need
GS competitive program WP0/WP1/WP2.1 (design/gamestream-competitive-program.md in the
planning repo). Four latency/overshoot gaps between the Moonlight-compat plane and the
native plane, all closed by wiring GS into machinery the native plane already had:

- WP1.1 arrival-driven capture: the GS encode loop slept out its whole tick and then
  SAMPLED, holding a frame that arrived just after the previous sample for up to a full
  interval (~half on average, ~8 ms at 60 fps). It now sleeps to a 0.9-interval floor and
  wakes on the capture's actual arrival, exactly the native T1.1 shape — with the same
  credit bucket pinning the long-run average at the negotiated rate, so a mirrored panel
  running faster than the session cannot overdrive the wire. The bucket
  (CaptureCredit, ex the native loop's private PaceBudget) and the PUNKTFUNK_FRAME_DRIVEN
  gate move to send_pacing, shared by both planes.

- WP1.2 microburst pacing: the GS sender passed burst_bytes: None into the shared pacer,
  so EVERY frame — a 3-packet P-frame included — spread across 0.75 of the frame interval
  (an ~11 ms tail at 60 fps), and small frames still slept out the full budget. It now
  runs the native parameterization: an auto-sized burst leaves immediately (a normal
  frame goes out whole, unpaced) and only an oversized frame's overflow spreads, across
  the time it needs at ~3x the stream rate, bounded to ~2 intervals. The chunking stays
  BOUNDED (<=12 steps) — on that non-RT thread the per-step sleep overshoot must stay
  independent of bitrate.

- WP1.3 pooled packetization: the packetizer copied the whole AU into a staging Vec and
  allocated one Vec per shard (~3300 for a 4 MB IDR) plus unpooled parity, every frame.
  Datagram buffers now cycle sender -> packetizer -> wire through a recycle channel,
  parity goes through the pooled encode_into, and each payload byte is copied exactly
  once, straight from the encoder's buffer into its datagram. A poisoned-pool equivalence
  test pins the pooled path byte-identical to a fresh packetizer.

- WP1.5 wake-on-packet control: the ENet service loop was a 2 ms spin-sleep, so every
  client input/IDR-request datagram waited out the rest of the tick. The control socket
  now blocks with a 2 ms read timeout (same idle cadence and CPU); an arriving datagram
  wakes it immediately. rusty_enet's receive loop returns on the first produced event
  (verified in its transpiled enet_protocol_receive_incoming_commands), so the block only
  ever sits where the sleep used to.

- WP2.1 budget honesty: the encoder was handed the full negotiated bitrate and then 20%
  FEC parity plus per-shard framing went ON TOP — every session carried ~1.23x what the
  client configured, and on the constrained links where the setting matters the overshoot
  was the failure. The encoder rate is now derived UNDER the client's number
  (gs_encoder_bps, the compat twin of the native encoder_kbps_for_budget from ABR
  overhaul Phase 4), so the wire fits the budget the Moonlight user actually set.

- WP0 measurement: the wire's frame_processing_latency field (1/10 ms, the Sunshine
  extension Moonlight's overlay shows as 'Host processing latency') is now stamped per
  frame instead of hardcoded 0; the 1 Hz stats sample grows a send_spread stage measured
  on the sender thread; and the video tests gain a GS-wire loopback harness — a
  client-shaped reassembler running Cauchy RS recovery over lossy datagram subsets —
  locking layout + recovery against every later packetizer change.

Also: the mirror source's capture-format 'gpu' predicate now matches the virtual source's
(resolved_backend_is_gpu, not the zerocopy env knob).

Gates: Linux container (punktfunk-rust-ci) fmt --all --check clean, clippy --all-targets
-D warnings clean and non-vacuous, send_pacing 14/14, gamestream 81/81 (incl. the new
harness + equivalence + schedule pins). Windows .133 clippy/tests with nvenc,amf-qsv,qsv
run in parallel with this commit; macOS spot-check shows only the known cfg(linux) stub
errors. On-glass A/B (WP0.3 baseline matrix) still owed.
2026-08-27 15:01:55 +02:00
enricobuehler 891f165f82 Merge pull request 'A stall learns whether anything was dirty, and lateness stops cutting the bitrate' (#413) from worktree-standby-sink-investigation into main 2026-08-27 13:00:12 +00:00
enricobuehler 53621a0fe0 fix(core/abr): frames that died of lateness stop ratcheting the bitrate — the +50k bump exempts the all-late window
window_loss_ppm's frames_dropped bump fired even when lost == 0 && late > 0:
every presumed-lost shard eventually ARRIVED, the frames died of lateness —
a delivery hole (host compose stall's resume edge, client-radio pause), which
neither lever this number drives can touch: FEC repairs loss, not delay, and
a bitrate backoff cannot shorten a hole. In the 2026-08-27 field log that was
loss_ppm=50000 exactly, x0.7 three times to 6.86 Mbps in 4 s, on a wire with
zero measured loss — the false ratchet is why a 160 ms hole reads as a
quality crater on our stream while the same pause is a cursor hiccup on the
competition.

The bump survives everywhere it is right: silent total loss (shards that
never arrived count in neither recovered nor late) reads late == 0 and still
bumps, and any window with real net loss bumps as before. Client-side mint,
so old clients keep the old behavior until updated.
2026-08-27 14:33:36 +02:00
enricobuehler 1121734141 feat(capture/stall): a stall learns whether anything was dirty — the cursor witness splits input pauses from display freezes
The 2026-08-27 NVIDIA-laptop field case (36 stalls/27 s, blamed on the dark
laptop panel) reattributes to damage starvation: the desktop's only damage
source is the DWM-composited cursor, the cursor moves only while the client's
input arrives, and DwmFlush stayed 8-33 ms through every hole — DWM was
composing all along and correctly presenting nothing, because nothing was
dirty. The detector could not see that, so it fitted a 1.87 s 'display
metronome' to the reporter's input cadence and the WARN put the panel on
trial (connected_inactive lists it on every laptop under the exclusive
isolate — a standing correlation trap).

- try_consume samples GetCursorPos (rate-limited, user32 only) and
  accumulates motion per gap; the stall-ending frame's own move is held back
  one call so it never counts into the gap it ended. Some(0) = nothing was
  dirty anywhere; Some(n>0) through a present-free hole = damage existed and
  the display stack composed none of it — the positive conviction
  CONTENT-SILENCE could never make.
- window_report attributes the pre-hole flow: flow_dwm_only = every lookback
  present came from dwm.exe — a game's holes are never demoted.
- classify: ComposeSilence + working witness + flow_dwm_only + cursor still
  => new class DAMAGE-IDLE. Excluded from the metronome (fed post-
  classification via StallWatch::cycle now), both repeated-stall WARNs, and
  the connected_inactive blame; per-stall lines carry
  cursor_moved_px_during_gap + flow_dwm_only either way.
- The below-OS METRONOMIC warn stops prescribing panel A/Bs for holes the
  witness can already explain, and says every pre-0.28 field metronome ran
  with the REALTIME lever default-on.

Logic verified by extracting stall.rs verbatim (+ the etw pure slice) into a
native scratch crate: 16/16 tests green, incl. the new damage_idle_split and
damage-idle-does-not-feed-the-metronome cases. Windows-only glue (GetCursorPos
sampler, field inits) is compile-gated on the Windows legs.
2026-08-27 14:33:26 +02:00
enricobuehler 44929dcbf8 Merge pull request 'The daily-driver five: 10-bit SDR, system keys for remote desktop, keep-host-audio, mode variables for prep, and a cursor sized to the client' (#412) from worktree-daily-driver-five into main
Reviewed-on: unom/punktfunk#412
2026-08-27 11:34:43 +00:00
enricobuehler 0b473cdb38 Merge pull request 'The couch tiles stop minting a console window' (#411) from worktree-win-msix-console-flash into main
Reviewed-on: unom/punktfunk#411
2026-08-27 11:34:16 +00:00
enricobuehler ee5e89d7d3 feat: the daily-driver five — 10-bit SDR, system keys in desktop mode, keep-host-audio, mode env for prep, cursor at client DPI
Five field requests from one returning user, traced and fixed together
(report: the "Daily-Driver Five" triage).

**10-bit SDR, independent of HDR.** VIDEO_CAP_10BIT and VIDEO_CAP_HDR were
always separate wire bits, but everything above the wire welded them: the
client advertised both from one "HDR" switch, the handshake required an HDR
capture source for any 10-bit depth, SessionPlan derived hdr from bit_depth,
and the encoder stamped BT.2020 PQ on every 10-bit format. Now a client
setting ("10-bit SDR", default off, tier-P) advertises the depth bit alone;
the handshake resolves bit_depth=10 without the HDR gates where the SDR-10
chain exists (Windows IDD-push + direct NVENC + HEVC — everywhere else the
session stays 8-bit, said honestly in the Welcome); the Welcome's colour
label, the virtual display's HDR bring-up and the capturer's want-HDR flag
all follow the new session-hdr verdict instead of the depth. The capture
side grows PixelFormat::Rgb10a2Sdr — the BGRA slot expanded 8→10 by a
trivial full-res pass (HdrRgb10Converter::new_sdr_expand) — which NVENC
ingests as ABGR10 and encodes Main10 under the ordinary BT.709 SDR VUI,
both 4:2:0 and 4:4:4. The win is the ENCODER's precision (compression
stops adding banding to gradients); the desktop stays 8-bit and neither
display's colour state is touched. The client already decodes by the VUI,
so no decode-side change was needed.

**"Capture system shortcuts" now works in Desktop mouse mode.** The setting
was wired end to end and then discarded by one `!desktop` term in the
presenter's grab condition — documented as "a remote desktop is something
you Alt-Tab away from", which made the toggle inert for exactly the
remote-desktop use it matters most for. The keyboard grab now follows the
setting in BOTH mouse models; desktop mode keeps an escape capture mode
never had (the unlocked pointer clicks any other window, focus drops, the
chords come back). Docs updated; the Mac's ⌘-chord behaviour is unchanged.

**Keep host audio playing (Moonlight's "Mute host PC speakers", unchecked,
per session).** New client setting + CLIENT_CAP_KEEP_HOST_AUDIO (0x20,
request-only, no HOST_CAP echo — an older host just re-routes as always).
A session holding the bit gets the host's audio devices left alone for its
lifetime (RAII guard): Windows skips the IPolicyConfig default parking and
loopbacks the operator's own default device; Linux taps the default sink's
monitor instead of claiming the default. Host-global best-effort across
concurrent sessions, as the cap documents. Rows in the GTK, Windows and
console-UI settings (desktop-only until Android advertises the bit).

**Prep commands carry the negotiated mode.** A per-app do/undo step's whole
environment was PF_APP_ID; the width/height/refresh/HDR it needs for a
per-mode frame cap were resolved 80 lines above and written only to the
unix-only marker file. Both serving planes now pass PF_STREAM_WIDTH /
_HEIGHT / _REFRESH / _HDR (the marker file's vocabulary, via one shared
hooks::prep_mode_env) — so an RTSS cap is one step instead of one
hard-coded entry per device, on Windows hosts too.

**The forwarded cursor matches the client's DPI.** The desktop-mode pointer
was resampled by the video fit factor alone, and SDL shows a custom cursor
surface at ~1:1 physical pixels on every backend — so on a 200% client our
pointer was half the size of every native one (and X11 the same; Wayland
escapes via buffer scale). The resample factor now folds in
SDL_GetWindowDisplayScale, the same model the Apple client gets for free
from point-sized NSCursors.

Verified: workspace clippy --locked -D warnings + targeted tests (hooks,
profiles, caps, console-ui rows, capture-policy) green on Linux
(pf-gtkflow); pf-capture/pf-frame windows-msvc green via xcheck; rustfmt
clean. NOT verified: the Windows-only halves of pf-encode/punktfunk-host
(no reachable Windows toolchain — .133 down; windows-host.yml is
dispatch-only, see the preceding commit), and everything on-glass: the
SDR-10 stream end to end, the desktop-mode grab, keep-host-audio wiring,
prep env on a live launch, and the cursor size at 200%.
2026-08-27 13:05:01 +02:00
enricobuehler 2b6bde577a fix(host): the Welcome-time display prep has not compiled on Windows since #408
bbc01cdd (ABR overhaul Phase 4, merged today as #408) threaded the new EncDerive
through prepare_display, but the call site in native/handshake.rs passes enc_of
BEFORE bit_depth while the signature takes bit_depth first — a type error in a
cfg(target_os = "windows") block. Nothing caught it: the ci.yml clippy job runs
on Linux and never compiles this block, and windows-host.yml only runs on
dispatch/release, so main's Windows host is currently unbuildable and no check
is red.

Reorder the arguments to match the signature. (--no-verify: this commit is cut
as a minimal cherry-pickable hotfix from a branch whose full fmt-clean state
lands in the following commits.)
2026-08-27 13:05:01 +02:00
enricobuehler de19a98f1b fix(clients/windows): the couch tiles stop minting a console window
`punktfunk-session` keeps the CONSOLE subsystem on purpose — its stdout is the
shell/session contract. Both couch entry points are GUI processes with no console
to lend it, so a spawn without CREATE_NO_WINDOW makes Windows mint one: a black
terminal that then sits behind the couch UI for the whole session. The shell's
own stream spawns (spawn.rs) have carried the flag since the port; the two
`--browse` hand-offs never did — the "Punktfunk Console" MSIX tile and
`punktfunk-client --console` (Steam shortcut, HTPC startup item) hit it every launch.

The two hand-offs were copy-paste twins, which is how they drifted apart on one
flag, so they become one file (couch.rs) that both roots include — the standalone
`punktfunk-console.exe` bin by `#[path]`, the shell as a module. `session_binary()`
folds in there too, so there is one resolver rather than two.

Second, quieter bug in the same spawn: stdout was inherited. With a console that
was merely invisible output; with CREATE_NO_WINDOW the inherited handle is invalid,
and `match_window` reports the settled window size on stdout REGARDLESS of
`--json-status` — a `println!` onto a dead handle panics the child mid-stream. So
stdout is an explicit `Stdio::null()`, not an inheritance.

Verified: couch.rs type-checks and clippies clean against x86_64-pc-windows-msvc in
an isolated harness (the crate itself cannot be cross-checked from macOS — SDL3 and
aws-lc-rs do not cross-build). On-glass verification on Windows is owed.
2026-08-27 12:57:14 +02:00
enricobuehler 45d3ff6ab7 Merge pull request '"Send logs to host" reaches every UI, and a console that cannot start says so' (#410) from worktree-gamepad-ui-and-send-logs into main
Reviewed-on: unom/punktfunk#410
2026-08-27 10:48:57 +00:00
enricobuehler 91fa05bc21 Merge pull request 'The web console is named in every install line, and a host that has none gets one' (#409) from worktree-fedora-web-console-install into main
Reviewed-on: unom/punktfunk#409
2026-08-27 10:48:30 +00:00
enricobuehler 0d8b156dcc feat(clients): "Send logs to host" reaches the GTK and WinUI shells, carrying the session's trail
The ring layer moves out of the session binary into pf_client_core::logring
(desktop-gated beside the ring it feeds) — it started as punktfunk-session's
private module, and the moment both desktop shells wanted the row, a copy per
bin was exactly the drift the crate exists to prevent. The session keeps the
same layer under its new name; tracing-log and log fall out of its manifest.

Both shells install it beside their visible layer with its own DEBUG filter —
the env filter keeps scoping only what's printed, because the ring exists for
the diagnostics nobody enabled before the bug happened.

The row itself lands on each shell's saved-host menu on the console's own gate
(paired + online), wired to logring::send_to_host on a worker thread, outcome
as a toast (GTK) / the status line (WinUI), in the console's wording verbatim.

And the bundle is worth sending: orchestrate now pipes the session child's
stderr through logring::forward_child_stderr — every line still lands on our
stderr exactly as inherit interleaved it, and lands in the ring too. Without
that, the one surface a GUI-only user can export held everything EXCEPT the
stream it was exported about — the same hole the WinUI logfile module was
built to close for the file; its child forwarder now feeds the ring on the
same pass.
2026-08-27 12:27:40 +02:00
enricobuehler f6d85f11e2 fix(install): the web console is named in every install line, and a host that has none gets one
Reported from the field: the guided installer (scripts/install.sh) left a Fedora box with a host
and no web console — and then printed the console's URL anyway. Reproduced with the real script
against a faked Fedora 44 os-release: with `punktfunk-host` on PATH the installer prints "already
installed — skipping the install" and never runs its dnf line, so `punktfunk-web` is never added.
The console is where you pair, approve a device and change every setting, so that box can stream
nothing and offers no way to fix it.

The registry was never the problem: `punktfunk-web` is present in the fedora-44 and bazzite RPM
groups, the debian repo and the arch repo (checked all four). The install step was gated on ONE
binary while installing THREE packages.

  * scripts/install.sh asks per package (host / punktfunk-web-server / punktfunk-scripting) instead
    of skipping everything the moment the host binary exists. A box that got the host by hand, from
    an older docs line, or from a package manager set to drop weak deps now gets its console on a
    re-run. Installing a package that is already there is a no-op, so re-running stays safe.
  * The install lines NAME the console on every family. apt relied on `Recommends: punktfunk-web`
    and pacman on an optdepend, both of which vanish under `APT::Install-Recommends "0"` /
    `install_weak_deps=False` / plain pacman. dnf already named it. The packages keep their weak
    deps — a hard Requires would break the COPR build, which has no bun and ships no console — but
    no documented path leans on them any more.
  * The installer stops lying when the console is absent: step 6 warns instead of silently dropping
    punktfunk-web from the units it enables, and the closing banner offers the install line rather
    than a URL nothing serves.

Verified unchanged on the channels that were already right: the Bazzite sysext bakes in web +
scripting, the NixOS module defaults `web.enable` to `host.enable`, the SteamOS script defaults
`WITH_WEB=1`, and the Windows installer bundles the console unconditionally behind a payload guard
added after 0.22.1/0.22.2 shipped without one. COPR is the documented exception (no bun in mock).

The gates that let this ship:
  * check-docs-drift.sh gate 7 asserted the install line only as far as the host package name, so
    a line that dropped the console still passed. It now names all three per family.
  * installer-smoke.yml asserted `punktfunk-host --version` and nothing about the console. It now
    asserts the binary and the user unit per family, and carries the reported failure as a test:
    remove punktfunk-web, re-run the installer, require it back.

data/platforms.json is the source for both the docs and the installer, so the per-distro pages,
the install landing page and the web-console page were updated with it — including a "no console on
a box that has the host?" recovery block for anyone already in this state.
2026-08-27 12:09:59 +02:00
enricobuehler 1dbee2e7ed fix(clients/android): "Send logs to host" reaches the touch UI, and a console that cannot start says so
Two halves of the same field report: a 0.31.4 user whose gamepad UI never
activates — on Always, with a pad attached — and who then has no way to send
anyone the log that would say why.

"Send logs to host" existed only in the Skia console's host menu. That put the
log-escape hatch behind the very shell that was failing: a phone whose console
does not come up has no route to its own logs at all. The upload moves out of
SkiaConsole into `SendLogs`, one implementation both shells call, and the touch
home's card menu grows the row on the console's own gate (paired + online, host
card only). The wording stays the desktop console's verbatim, so a quoted
message means the same thing on every client.

The activation half is a gap in what the app admits. `App` folds
`SkiaConsole.wanted() && healthy` into the gamepad-UI decision on top of the
user's setting, and those two terms are the ONLY ones that can veto "Always" —
the mode, the pad, the TV check and the dev flag are ORed, so a device whose
console never builds ignores every one of them. Until now that produced a switch
the app silently disobeyed, indistinguishable from the switch being broken.
`SkiaConsole.unavailable()` names the reason and the Interface settings group
prints it as its footer: null on every device where the console works, so it
costs nothing there.

The reason string itself was already in the ring the new row uploads —
`console: render thread ended: …` and `console: window surface: …` go through
`log::error!`, which `RingTee` tees into `pf_client_core::logring`. So the two
halves close the loop: the user stuck in the touch UI can now send the log that
names why they are stuck in it.

Verified: 98 app unit tests green, `:app:assembleDebug` clean, native builds for
all three ABIs.
2026-08-27 12:05:09 +02:00
enricobuehler 9615cd7612 Merge pull request 'The bitrate becomes the wire budget it always claimed to be' (#408) from worktree-abr-phase4 into main
Reviewed-on: unom/punktfunk#408
2026-08-27 09:34:47 +00:00
enricobuehler bbc01cdd8a The bitrate becomes the wire budget it always claimed to be (ABR overhaul Phase 4)
'20 Mbps' used to mean 22-30+ Mbps on the wire: FEC parity (1-50 %),
packet framing (~4.5 %) and the audio plane all rode on top of the
number the user set — and on the constrained links where the setting
matters most, that overshoot WAS the failure. Per the signed-off RFC
§5.1 (amended 2026-08-27: no capability handshake — the semantics just
change; new-client→old-host is byte-for-byte today's behavior, and
old-client→new-host shifts conservative by the overhead share):

Host: every number on the control plane — Hello resolve, SetBitrate,
acks, live_bitrate, the console — is the TOTAL WIRE BUDGET. Encoder
opens/reconfigures convert through EncDerive (budget − audio, over
framing + FEC) snapshotted at the live FEC percent, and read-backs
convert back so a short apply reports budget truth. A FEC step
re-derives the encoder rate in place, so parity reallocates WITHIN the
budget instead of inflating the wire. The audio reservation comes from
the resolved plane (exact PCM cost, or the shared Opus budget ladder).
PyroWave is the identity (its pin is an encoder operating point);
GameStream keeps its historical semantics untouched.

Client: the controller's actual becomes the WIRE measure — received
bytes with headers, seals and parity (they spend the budget), minus
probe filler, plus the mirrored audio reservation — so utilization and
the proven mark compare like with like against budget targets.

The derivation is pure and unit-tested: roundtrips never inflate the
budget, reallocation is monotone in FEC, and a budget too small for
its own audio floors honestly. punktfunk-core --features quic: all 500
lib tests green natively; punktfunk-host clippy -D warnings green on
the CI image. Docs: the bitrate sections say what the number now means.
2026-08-27 11:29:35 +02:00
enricobuehler b97fa1186f Merge pull request 'The controller learns stillness from the host, and motion stops reading as congestion' (#407) from worktree-abr-phase3 into main
Reviewed-on: unom/punktfunk#407
2026-08-27 09:22:20 +00:00
enricobuehler 5f77074e51 Merge pull request 'A tag is a pointer, not a version — pin every checkout/cache to a commit' (#405) from worktree-actions-pin into main
Reviewed-on: unom/punktfunk#405
2026-08-27 09:21:12 +00:00
enricobuehler 8358f9ed37 Merge pull request 'The AMD field log answers itself, and AMF reports its real bitrate' (#406) from worktree-amd-program-wave2 into main
Reviewed-on: unom/punktfunk#406
2026-08-27 09:20:43 +00:00
enricobuehler cba7587684 chore(ci): baseline the two new wire constants — protocol internals, not knobs 2026-08-27 11:03:08 +02:00
enricobuehler 2b107f5a18 chore(core): prefix the two new exported constants in the C header (R21) 2026-08-27 10:42:00 +02:00
enricobuehler a387ddd4ab The controller learns stillness from the host, and motion stops reading as congestion (ABR overhaul Phase 3)
Static content used to poison the client's ABR against the first moment
of motion (the 2026-08-26 field chain, step 5), and a frame-driven
source could never climb at all (the documented dead band). Four
changes, per the signed-off RFC §4.1–4.3 + §7 Q4:

- The host marks idle-keepalive re-encodes on the wire
  (USER_FLAG_REPEAT; whole-frame and streamed paths) and says so in a
  new trailing Welcome byte (host_caps2 / HOST_CAP2_REPEAT_MARK — the
  second capability byte the 0x80 wall predicted, same append
  discipline, placeholder chain extended one link). Old clients ignore
  both; against an old host the client keeps every legacy rule.
- Idle windows (every AU a repeat) are NEUTRAL: they train no OWD/
  decode/encode baselines, accrue no climb credit or re-probe
  authority, and never authorize a climb — loss/flush/drop keep their
  full power. The pinned climb-gate test is extended, not deleted.
- Utilization is measured per frame at the source's own rate: a 35 fps
  menu on a 90 Hz config climbs when its frames run full. The
  proven-headroom cap prorates with it — it bounds the next target's
  projected WIRE rate at ×1.5 over what was delivered — or the two
  gates would deadlock exactly where the fix is needed.
- Recovery is bounded and multiplicative: the proven mark is a
  two-bucket windowed max (~30–60 s) instead of all-session, and the
  first active window after ≥3 s of stillness re-arms slow start with
  the cooldown cleared — the ~103 s additive crawl from the floor
  becomes seconds, never past ×1.5 over recently-proven delivery.
- FLOOR_KBPS drops 5000 → 2000 (RFC §7 Q4), with a one-shot log
  warning on the first descent below the old fence; the client-settings
  doc says so.

punktfunk-core --features quic: all 500 lib tests green natively,
including six new field-shaped scenarios (frame-driven climb, idle
neutrality head-to-head vs a legacy host, bounded onset re-arm,
windowed proven decay, floor + warning, idle-never-climbs).
2026-08-27 10:40:48 +02:00
enricobuehler 5da68ab858 Merge main — the ABR review's four items landed there first (#399-#404)
Phases 1+2 of the ABR overhaul implemented review §2.1/§2.2/§2.3/§2.4 while this
branch was doing the same, so every ABR conflict resolves to main's side and my
duplicates are deleted:

- abr.rs: main folds the mode-switch rebind into set_stream_cap itself (a
  `mode_switch` flag) instead of a second method — my rebind_stream_cap and its
  test are removed; main's a_mode_switch_reteaches_the_stream_cap_both_ways
  covers strictly more (up-switch, re-authorize, down-switch, disabled).
- pump.rs / control_task.rs / data.rs: main's ack queue and stream-cap recompute
  are the same shape; taken wholesale.
- stream.rs: main's §2.2/§2.3 arms taken (its gap cast saturates, mine did not).

What this branch still carries is what main does not: the capture-stall telemetry
(posture line, repeated-stall WARN, CONTENT-SILENCE prose), the AMF work
(applied_bitrate_bps readback, accepted-props logging, AV1 B-picture pin,
no-reordering assertion), the adl-emul probe fixes, and the wire-MTU-re-key
attribution arm.
2026-08-27 10:26:57 +02:00
enricobuehler 2cd5787ed6 fix(encode/amf): pin AV1 B-pictures off, and assert no-reordering on the bitstream
H.264 already pins BPicturesPattern=0 ('RDNA3+ defaults > 0'). AV1's three
B-picture properties are VCN5 features (AMF header, verified 2026-08-26) whose
defaults are 0/false today — but a default that flips on newer silicon is
exactly how H.264 grew them, and AV1 is the one AMD codec with no LTR and no
intra-refresh, so a reordering surprise lands on the codec that can least afford
it. Optional sets: pre-VCN5 drivers decline the names, which is the right no-op.
HEVC needs no twin — AMF defines no B-frame property for it at all, and a VCN3
capture measured 3 I + 52 P + 0 B.

The live smoke test now asserts AUs leave in submit order, so a driver that
silently declines a pin fails the test instead of shipping reordered output.
2026-08-27 10:19:12 +02:00
enricobuehler 72959ef07d fix(core): bitrate acks queue in arrival order instead of a latest-wins slot
08-22 ABR review §2.4: a full resolve ack plus a corrective short retarget in
the same 750 ms report window collapsed to whichever arrived last — host-cap
learning needs two consecutive short acks, so a lost correction delayed or
prevented the cap and could reintroduce the overdrive sawtooth. Bounded queue
(8), drained fully per window.
2026-08-26 21:20:45 +02:00
enricobuehler fa0f66e151 fix(core): a mode switch re-sizes the ABR stream cap, and clamps the learned ceiling with it
08-22 ABR review §2.1: the stream-shape cap was computed once from the Welcome
mode and never again, so a 4K→720p switch kept authorizing 4K-sized climbs for
the whole session (only the reactive loss/decode signals reined them in). The
mode-gen site now recomputes the cap from the accepted mode (depth/chroma are
session-negotiated and ride along) and rebind_stream_cap clamps an already-
learned ceiling down to it. Up-switches lift only the cap — with no untrimmed
measurement stored, a higher ceiling would be evidence-free (§3.3 re-probe owns
that half). Pinned by a_mode_switch_rebind_clamps_the_learned_ceiling_but_never_raises_it.
2026-08-26 21:15:45 +02:00
enricobuehler d3a5f13a45 ci: pin every checkout/cache to a commit, and move off the stale v4 majors
A tag is a pointer, not a version. `actions/checkout@v4` re-resolves on
every run, so whoever controls that tag controls the first step of nearly
every job in this repo — including the jobs holding signing keys. 52
checkout sites and 31 cache sites now name a commit; announce.yml's lone
existing SHA pin moves up with them so the tree is uniformly pinned rather
than half-pinned, which is the state where a reader cannot tell a
deliberate omission from a miss.

checkout v4 -> v7.0.1, cache v4 -> v6.1.0, both by commit.

The reason this needed proving rather than just doing: v5+ of both declare
`runs.using: node24`, and Gitea's runner only accepts node24 from a certain
version on. Checkout is step 0 of almost everything here, so getting it
wrong fails the whole fleet at once — including the runs that would tell
you it broke.

What was established first:
- every runner serving this repo is Gitea Runner v1.0.8, whose vendored
  act accepts node24;
- `runs.using` is only a validation gate — act execs a BARE `node` from
  PATH, so the image's node is the real interpreter, and the oldest one
  here is 18.19.1 (ubuntu:24.04's nodejs, in rust-ci-noble);
- so checkout v7's node24-targeted ESM bundle was run under that exact
  node, in that exact image: it parsed inputs, drove `git` through
  @actions/exec, created .git, set up and tore down credential config, and
  ran its post step. It reached a genuine git error against the synthetic
  file:// remote used for the test — not a module-load or missing-API
  failure. It works.
- actions clone straight from github.com here (no Gitea mirror), so these
  SHAs are meaningful; both tags are lightweight, pointing directly at the
  commits pinned.

No breaking change in v5..v7 applies: this repo passes zero checkout
inputs, and uses neither `pull_request_target` nor `workflow_run` (v7's
fork-PR restriction). cache v5/v6 pick the same legacy service path as v4
because the runner never sets ACTIONS_CACHE_SERVICE_V2.

upload-artifact@v3 is deliberately NOT swept — it is pinned low on purpose,
because Gitea's artifact backend identifies as GHES and v4 refuses it.
2026-08-26 21:08:11 +02:00
enricobuehler 3fe1af991a fixup: publish wire_rekeys from the send thread (it owns the packetizer) 2026-08-26 21:08:04 +02:00
enricobuehler 57444e7be7 fix(host): a wire-MTU-re-keyed session stops blaming its metronomic recoveries on the display
The 2026-08-26 lab sessions over an overlay hop (udp_mtu 1336) produced the
'host/display disturbance' warn at period 1.7 s — just outside both client
cooldown bands — while the real cause was the path black-holing full-size video
until the re-key. Period alone cannot make this call; the session's transport
context can. New first-priority arm: wire_rekeys > 0 names the constrained path
and points at PUNKTFUNK_WIRE_MTU.
2026-08-26 21:03:36 +02:00
enricobuehler 809f1faa26 The comments were the spec, and the code had drifted — 2026-08-25 security review (#396) 2026-08-26 18:56:30 +00:00
enricobuehler d34d431618 Merge pull request 'DSCP marks the peers the local network owns' (#404) from worktree-abr-dscp-default into main 2026-08-26 18:44:40 +00:00
enricobuehler 7ebdd7e5d9 fix(ci): pin cargo-ndk too — it builds the shipped Android .so files
Last floating tool in the builder images. Unpinned, two rebuilds months
apart bake different cargo-ndk versions with nothing recording it, and this
is the tool that drives the .so files in the published APK/AAB. crates.io
is append-only with a checksummed index, so the version string is the whole
pin — no checksum needed, unlike the bun/sccache/FFmpeg cases.

4.1.2 is current, so this is a zero-change pin like the others.
2026-08-26 20:26:27 +02:00
enricobuehler c62cfd58de fix(ci): a git tag is not a pin — assert the FFmpeg commits we actually build
rust-ci-noble builds FFmpeg and nv-codec-headers from `git clone --branch
<tag>`, and the .so files land in the host .deb every Ubuntu user
installs. Tags are mutable: upstream can repoint one and, unlike a branch,
nothing would look different. The comment above it claimed "immutable-tag
clone, so no separate checksum needed", which is the same
comment-asserts-what-the-code-doesn't shape this whole branch has been
unpicking.

Both are now asserted against the commit the tag points at TODAY, so the
built tree is byte-identical and only the mutability is closed. A moved tag
now fails the build loudly, matching the sha256sum -c pins next door.

Both tags are ANNOTATED, so the bare ref is a tag object, not a commit —
the pin is the peeled `refs/tags/X^{}`, which is what `rev-parse HEAD`
returns after a --depth 1 tag clone. Pinning the tag object would have
failed every build.

rustup's installer and `cargo install cargo-ndk` are left: both float, but
crates.io is append-only with a checksummed index and rustup verifies its
own downloads — neither is a fixed name whose bytes can move.
2026-08-26 20:24:51 +02:00
enricobuehler 1a604cbf3e Merge remote-tracking branch 'origin/main' into worktree-abr-dscp-default
# Conflicts:
#	docs-site/content/docs/configuration.md
2026-08-26 20:16:31 +02:00
enricobuehler c1bffa9e7b Merge pull request 'The control loop stops believing its own bookkeeping' (#403) from worktree-abr-control-loop into main 2026-08-26 18:14:54 +00:00
enricobuehler 8ca86b1682 Merge pull request 'The transmission plane stops decaying to a blast' (#401) from worktree-abr-transmission-plane into main 2026-08-26 18:14:29 +00:00
enricobuehler dcedd7147f Merge pull request 'PyroWave forces Automatic bitrate' (#399) from worktree-pyrowave-forces-auto into main 2026-08-26 18:14:10 +00:00
enricobuehler 4dcc31dc3b Merge pull request 'Verify NVENC sub-frame readback in release, and stop trusting a driver that lied' (#400) from worktree-nvenc-subframe-verify into main 2026-08-26 17:52:06 +00:00
enricobuehler 987ecabfde docs(nix): key the deploy host key port-agnostically, and say which address to scan
Two things that cost real time setting DEPLOY_KNOWN_HOSTS for the first
time. ssh looks a host key up by the exact string it dialled, so a plain
entry stops matching the moment DEPLOY_PORT is not 22 — and it fails
looking like a host-key error, not a formatting one. The comma form covers
both. And DEPLOY_HOST is unom-1's public Hetzner IP since the 2026-07-12
cutover, so a stale private address in an SSH config is the wrong thing to
scan.

Also say ed25519 only: pinning every type ssh-keyscan prints means the
weakest pinned key decides, since a host offering just RSA would satisfy
the check on the RSA line.
2026-08-26 19:35:28 +02:00
enricobuehler 2bb6af3b92 Merge pull request 'A launcher tile can open Heroic's console mode' (#402) from worktree-heroic-console-and-runners into main 2026-08-26 17:26:18 +00:00
enricobuehler 2a951a6bb5 DSCP marks the peers the local network owns (ABR overhaul Phase 1, §2.5)
The marking was opt-in because some consumer ISPs/routers bleach or
reject DSCP-tagged packets — but that risk lives on WAN paths, and the
win (APs map DSCP to WMM airtime priority — real for exactly the Wi-Fi
downlink field case) lives on local ones. The default becomes AUTO:
mark toward RFC1918 / ULA / link-local / loopback peers, stay off
toward anything routable.

PUNKTFUNK_DSCP=1 still forces it on everywhere, =0 stays the kill
switch, and the embedder hook (the Android low-latency tie-in) still
forces on regardless of the peer — for paths like VPNs the address
math can't recognize as local. Decision + classifier are pure and
unit-tested; the connected-loopback test covers the AUTO path end to
end. Windows qWAVE mechanics unchanged.
2026-08-26 19:16:31 +02:00
enricobuehler 5bf64e07bf The control loop stops believing its own bookkeeping (ABR overhaul Phase 2)
The four correctness seams from the 08-22 auto-bitrate review §2, chosen
options per the RFC (planning design/abr-stack-overhaul.md §3):

- §2.3: a failed bitrate-change encoder rebuild now snaps the client back
  (retarget_tx) — the control task acks BEFORE the apply, so the client's
  climb base, utilization and proven math tracked a rate the encoder
  never ran until some later event happened to correct them.
- §2.2: the ABR rebuild announces PipelineGap on success, like the
  mode-switch and topology rebuilds already do — a ~0.6 s host-local
  stall read as congestion killed slow start for the session (the 401 ms
  field case: minutes at ~15 Mbps on a clean link).
- §2.1: an accepted mode switch re-teaches the stream-shape cap —
  computed once from the Welcome mode, 1080p→4K kept a 1080p-sized climb
  ceiling and 4K→720p left an oversized one standing. A re-set
  set_stream_cap also rebinds the already-learned ceiling downward
  (set_ceiling deliberately never lowers); the FIRST set keeps the
  founding semantics, pinned by the existing stream-bound test.
- §2.4: the bitrate_ack slot becomes a queue drained in arrival order —
  latest-wins collapsed a full resolve ack + corrective short retarget
  landing in the same 750 ms window, and host-cap learning needs two
  CONSECUTIVE short acks.

punktfunk-core --features quic: 490 tests green natively, including the
new a_mode_switch_reteaches_the_stream_cap_both_ways.
2026-08-26 19:10:28 +02:00
enricobuehler ae13b29abd feat(host/library): launcher_ui can open Heroic's console mode
Heroic 2.21 added a fullscreen gamepad UI — the Big Picture of that launcher,
and the tile you actually want on a stream. Nothing could publish it: a plugin
names a launcher UI and the host builds the line, and the only Heroic value the
host knew was `heroic`, which opens the ordinary window.

`launcher_ui` now also takes `heroic-console` on Linux, resolving to the same
prefix `heroic` does — native binary if on PATH, else the Flatpak — plus
`--console --fullscreen`. Both flags, because Heroic reads them separately:
`--console` only routes the UI to that front end (`isCLIConsoleMode`) and
`--fullscreen` is what fills the screen (`isCLIFullscreen`). No URI can do it —
`heroic://` speaks `ping` and `launch` and nothing else — which is the same
reason Playnite's fullscreen tile spawns its exe directly rather than going
through the registered protocol handler. An older Heroic ignores the unknown
`--console` and still honours `--fullscreen`, so the tile degrades to a
fullscreen desktop UI rather than to nothing.

That makes the value a launcher UI rather than a launcher. It already was one on
Windows, where `playnite` has always meant `Playnite.FullscreenApp.exe`; the doc
comment claiming otherwise is corrected. A `heroic_ui` kind mirroring `steam_ui`
would have been tidier and was rejected on the compatibility asymmetry: an
unknown KIND degrades to an unlaunchable tile, but an unknown VALUE is a hard
400 that refuses the whole reconcile, so either shape has to be gated on
`minHost` in the plugin index and the value is much the smaller change. A plugin
publishing `heroic-console` must set `minHost` to the release carrying this.

Second fix, from the same reading: `resolvable_launcher_ui` special-cased only
Playnite-on-Windows and answered `true` for everything else, so a Heroic tile
survived `sanitize_launcher_entries` even when `heroic_launch_prefix()` resolves
to nothing. Keeping `~/.config/heroic` after uninstalling Heroic is enough to
reach that — the plugin's `detect` only looks for the directory — and the
operator got a launcher tile that does nothing. Both Heroic values now probe the
prefix, exactly as Playnite probes for its exe.
2026-08-26 19:09:56 +02:00
enricobuehler 9c278ee351 The transmission plane stops decaying to a blast (ABR overhaul Phase 1)
Static content used to strip every defense before motion: the unpaced
microburst allowance was an absolute max(128 KiB, wire/4) — sized for
gigabit LAN, so at Wi-Fi bitrates every frame went out back-to-back —
and adaptive FEC decayed to 1 %, so the first big motion frame died
unprotected (the 2026-08-26 field case; RFC design/abr-stack-overhaul.md
in punktfunk-planning, §2.1–2.4).

- The allowance is now TIME at the pace rate: 10 ms, clamped to
  [16 KiB, 256 KiB] (send_pacing::auto_burst_bytes). One constant lines
  up both proven ends — ~19 KiB at the field case's 15 Mbps pace (its
  discriminator was PACE_BURST_KB=16) and ~112 KiB at 90 Mbps LAN pace,
  so LAN latency does not regress. PACE_FACTOR=0 keeps the legacy rule.
- A streamed AU consumes ONE allowance across its block flushes instead
  of a fresh one per block (the latent per-block multiplication).
- One frame's paced spread is bounded to ~2 frame intervals so an IDR
  can't back the encode|send sync_channel(3) up into cadence_degraded,
  which refuses every climb; MAX_PACE_SPREAD stays the absolute bound.
- Adaptive FEC gains a burned floor: any window with real shard loss
  raises the decay floor to 5 %; ~2 clean minutes re-earn 1 %, an early
  re-burn doubles the requirement (bounded), a durable step-down resets
  it — no session-permanent latch, per the encode stand-down's house
  rule. PUNKTFUNK_FEC_PCT pin semantics and the GameStream plane are
  untouched.

Pure logic (FecFloor, auto_burst_bytes, native_budget) is unit-tested;
the burned-floor band is noted in the FEC_PCT docs.
2026-08-26 18:58:28 +02:00
enricobuehler f79e9eb524 fix(encode): verify NVENC sub-frame readback in release, and stop trusting a driver that lied
The chunked poll ships slice bytes the moment the doNotWait lock publishes
them, trusting that they are a byte-exact prefix of the AU the finishing
blocking lock returns. The check for that trust — the ChunkState shadow
comparison — was compiled out of release builds, so a driver branch whose
sub-frame accounting runs ahead of the flushed bytes shipped not-yet-written
buffer content straight to the wire: self-consistent tiling, wrong bytes,
no client counter moving. That is the black-band field report (Windows
client, 4090 host, identical across all three decoders, Android immune —
it never negotiates the slice wire), and most likely the Linux/AMD grey
smears too.

Make the shadow prefix check unconditional (one AU-sized copy + compare
per frame — noise next to the encode), and on divergence latch
subframe_broken before bailing into the existing encode-stall recovery:
the in-place rebuild now resolves sub-frame OFF for every later session
open on this encoder, so the driver that lied is disarmed after exactly
one rebuild instead of looping into MAX_ENCODER_RESETS. The client sees
the abandoned-AU path it already handles (ages the frame out, IDR
re-anchors) — a one-second hiccup, once, instead of endless corruption.
Both direct-NVENC backends (Windows D3D11, Linux CUDA) get the identical
change; the bail path also stops leaking the popped frame's input mapping.
2026-08-26 18:45:20 +02:00
enricobuehler eed7b5e589 fix(ci): pin the tools the builder images bake in, not just the ones the jobs fetch
The workflow-level pins closed the bootstrap path only. On the normal path
the bun that gets vendored into the published .deb/.rpm comes from the
builder image, and every image still installed it with the upstream
installer piped into bash — so the script still chose bytes that ship to
users. rust-ci and fedora-rpm now take the same pinned, SHA-256-checked
bun-v1.3.14 asset the workflows do; arch-ci takes bun from pacman, which
verifies package signatures.

Same class, found while sweeping and worse than the cited sites: five
images fetched sccache over a version-pinned URL with no integrity check at
all. sccache is RUSTC_WRAPPER for every binary we ship — it can serve
poisoned object files straight into a signed package, which is the position
the review called the highest-leverage in a build. Download, verify against
upstream's published sum, then extract.

packaging/flatpak/build-flatpak.sh took flatpak-cargo-generator.py from a
mutable master, the same fetch flatpak.yml just pinned; both now name the
same commit and sum, so the local build and CI agree.

Arch note: bun rides the existing -Syu transaction rather than a later
layer. A separate layer resolves against the DB baked into the CACHED -Syu
layer, and Arch mirrors carry only current versions, so a cache-hit rebuild
months on would fail to fetch a package the stale snapshot names.

None of this takes effect until the images are rebuilt: docker.yml's
`builders` job keys on the git tree hash of ci/, so this re-keys the whole
family. Until then the workflow bun pins sit behind `command -v bun ||` and
short-circuit against the image's baked bun.

rustup's own installer is left piped, as apple.yml already does — pinning
rustup-init is a separate decision, and the same argument reaches every
image at once.
2026-08-26 18:17:26 +02:00
enricobuehler 933074fafc feat(win-display): the adl-emul Probe walks headless AMD adapters — the lab rung needs it 2026-08-26 18:12:05 +02:00
enricobuehler 54c3414f36 fix(win-display): the adl-emul probe says which adapters it skipped, and why
First .173 run: 15 logical adapters enumerated, zero connectors walked, exit 0,
no explanation — a probe whose deliverable is an rc must never end silent. One
record per distinct (bus, vendor, present) shape before the filter.
2026-08-26 18:11:07 +02:00
enricobuehler 637d438532 test(amf): live readback proof for applied_bitrate_bps on real VCN hardware 2026-08-26 18:00:27 +02:00
enricobuehler 1511374959 PyroWave forces Automatic bitrate (ABR overhaul RFC §5.2)
An explicit client rate under PyroWave was ill-defined (all-intra bpp
semantics — the operating point is bits per pixel, not kbps) and bypassed
the PUNKTFUNK_PYROWAVE_MAX_MBPS operator ceiling entirely.

Host: resolve_bitrate_kbps_for ignores the requested rate under PyroWave
(warn when overriding) so every PyroWave session goes through the per-mode
bpp pin + ceiling, and bitrate_auto treats PyroWave sessions as Automatic
so mode switches re-resolve the pin whatever the Hello carried.

Clients: pf-client-core sends bitrate 0 when the preference is an
ADVERTISED PyroWave (a failed decode probe falls back to H.26x, where the
user's rate must survive); the Apple client mirrors the same gate at its
own Hello site. The console UI and the Apple settings dim the bitrate
control with a one-line explanation instead of offering an inert rate.
The stored setting is preserved everywhere — switching codecs back
restores it.
2026-08-26 17:43:31 +02:00
enricobuehler f632ee68d2 fix(windows,ci,zerocopy): finish the deferred half — a real prompt, the rest of the pipes, and an arch bug CI cannot see
Follow-ups left open by the security-review pass.

The Windows client had the deep-link fix's enforcement but not its UX: a
name- or address-resolved link was REFUSED with a note pointing at the host
list, marked in-code as degraded, because that shell had no prompt surface
wired up. It has one — `ContentDialog`, already used for "Remove saved
host?" and "Delete profile?" — so the link now asks, naming the host and
anything it wants to launch, and on confirm runs the identical dial the
id-referenced path runs. Same wake, trust and error surfaces; confirming is
one click in front of the same code.

The supply-chain pins only covered the jobs the report cited. deb.yml
(a signing-key job) and arch.yml still piped bun's installer into bash;
flatpak.yml still took the deploy host key on first contact while holding
FLATPAK_GPG_PRIVATE_KEY. deb now takes the same pinned, SHA-256-checked bun
asset as rpm and windows-host; arch takes bun from pacman, which verifies
package signatures; flatpak verifies the host key from DEPLOY_KNOWN_HOSTS
through the fail-open-through-setup preflight it already had.

Found while sweeping, worse than the cited sites: flatpak.yml fetched
flatpak-cargo-generator.py from a mutable `master` and ran it in the job
holding the GPG key — third-party Python deciding which crate sources the
signed build vendors. Pinned to a commit and checksummed.

pf-zerocopy's EGL bindings declared glShaderSource/glGetUniformLocation as
`*const i8`, but `CStr::as_ptr()` yields `*const c_char` — i8 on x86_64,
u8 on aarch64. So the crate did not compile for aarch64 at all, and CI is
x86_64-only and could never see it. Verified both directions: with c_char
`cargo check -p pf-zerocopy --target aarch64-unknown-linux-gnu` passes,
and the old spelling still fails E0308 there.

Docs: the note saying Windows has no prompt is gone, and the first-connect
sentence put Windows in the wrong group — it pre-fills its trust prompt
from the link like Linux and Android; only the Apple apps show a notice.
Corrected an Apple comment claiming it seeds the add sheet with the address
and fingerprint when it only sets a notice string.
2026-08-26 17:40:31 +02:00
enricobuehler b43363b141 feat(capture,encode): AMD program wave 2 — the field log self-describes, and AMF reports its applied bitrate
- Every capture session stamps a 'GPU-priority posture' INFO line (both REALTIME
  opt-ins) at open, so a stalling log carries the levers even when no WARN fires.
- Repeated stalls WITHOUT a stable period now WARN with the full triage payload
  (tallies, suspects, levers) — the 2026-08-26 7700 XT log had 6 holes in 8 s and
  zero guidance because only the metronomic arms spoke.
- CONTENT-SILENCE prose stops overselling benignity: a frozen presenter
  (disturbance-immunity Flavor 3) reads identically, and our probes all run at the
  host's elevated GPU priority.
- Native AMF implements applied_bitrate_bps via a GetProperty readback (typed the
  existing vtable slot), so encoder_ceiling learning / the ABR overdrive guard stop
  being inert on AMD; optional-property rejections log at INFO and the encode-active
  line carries ltr/intra_refresh — the VCN capability matrix builds itself from
  field logs.
- Doc drift: PUNKTFUNK_GPU_PRIORITY_CLASS default is high (not auto) everywhere it
  is described; PUNKTFUNK_IDD_ADAPTIVE documented; troubleshooting names the
  REALTIME-lever first step and the new repeating-stall warning.

Design: punktfunk-planning design/windows-amd-host-program.md §3.1–§3.3 wave 2.
2026-08-26 17:32:26 +02:00
enricobuehler 1198522931 Merge pull request 'A slice block's lying base can no longer ship as a complete frame' (#398) from worktree-slice-tiling-guard into main
Reviewed-on: unom/punktfunk#398
2026-08-26 14:48:11 +00:00
enricobuehler 79982060c3 Merge pull request 'The Fedora install line must name punktfunk-web, not hope for it' (#395) from worktree-fedora-web-weakdep into main
Reviewed-on: unom/punktfunk#395
2026-08-26 14:45:13 +00:00
enricobuehler fc6060f274 Merge pull request 'KWin's stored setup darkened the desk behind our back, and a TV remote could not leave the first settings tab' (#397) from worktree-kwin-stored-config-strand into main 2026-08-26 14:44:44 +00:00
enricobuehler 4399664217 feat(console-ui): the legend speaks the device in hand — remote marks on TV, Nintendo's own letters, keys vs pad tracked at the source
Follow-up from the same Chromecast field report: the hint legend only ever
spoke gamepad (or desktop-keyboard keycaps), whatever was actually driving.
A TV remote got Enter/Esc/Tab — keys it does not have — and a Switch pad's
badges read the Xbox letters, so 'A Select' sat over the button engraved B.

The shell now notes WHAT drove last at every input seam — the Android host
loop labels its two menu paths (discrete events are the remote, the pad
sampler is the pad), the desktop overlay's menu channel is pad by
construction, and the keyboard path notes itself. Each frame the legend
resolves from that:

* a pad speaks its own family — PlayStation shapes, Nintendo letters (both
  pairs swapped, the way the pad is engraved), ABXY for the rest;
* keys on Android are a TV remote: an OK badge for select, the ↩ return
  arrow for back, the section hint pointing at the D-pad path (▲) that
  reaches the tab strip, and the Y/X hints hidden outright — a remote has
  neither, and advertising a button the device cannot press is worse than
  silence;
* keys on the desktop stay keyboard keycaps;
* nothing has driven yet: the connected pad's family, else the platform's
  key device — so an untouched console reads right on every device.

The controller chip grows a remote mark and says 'TV remote' when that is
what is driving; a pointer press changes nothing, since a tap says nothing
about the buttons in the user's other hand. The screen dump gains the two
remote frames (Home + Settings) for the eyeball pass.
2026-08-26 16:42:06 +02:00
enricobuehler e684b3e4bd fix(core): a slice block's lying base can no longer ship as a complete frame
A slice-streamed sentinel's wire base was bounds-checked (in range, below
the final block) but never validated to TILE the AU: a base that lied
within bounds left a zero gap and an overlap, and the reassembler still
stamped the frame `complete`. The decoder then painted the wrong-offset
bytes as garbage rectangles while no loss counter moved — so the recovery
machinery never fired and the corruption marched on indefinitely (the
field report: black bars 'moving like an equalizer', identical across
Vulkan/DXVA/CPU decoders, Android immune because it never negotiates the
slice wire).

At completion, verify the blocks tile [0..total_data] contiguously; a
frame that doesn't is killed exactly like a lying final header — index
remembered against stragglers, counted into frames_dropped, which is the
signal that arms the freeze gate and requests recovery. The
slice-progressive prefix walk gets the same contiguity guard so a lying
base can't extend a 'contiguous' prefix either.
2026-08-26 16:28:11 +02:00
enricobuehler 1b8d4799ef fix(inject): the doc naming the write we removed trips the grep gate
Gate C in check-unsafe-hygiene.sh matches the qualified `env::set_var`
anywhere in a file, comments included — deliberately, so the contract stays
dumb and stable. The new SESSION_BACKEND doc explained what it replaced by
naming it, which reads as a fresh call site to the gate. Reword rather than
raise the baseline: the baseline is there to catch a real write later, and
this change deleted one.
2026-08-26 09:32:39 +02:00
enricobuehler 8c4b1b8c62 style: the two folds rustfmt wanted (ci / rust fmt gate) 2026-08-26 09:29:30 +02:00
enricobuehler 98e68a49a7 fix(host,web,clients,ci): the comments were the spec, and the code had drifted
Security review 2026-08-25, 58 confirmed findings across host, console,
clients and supply chain. Nearly every serious one is a documented boundary
whose code stopped enforcing what its comment promised — so where the two
disagreed, the comment won and the code was made to match, and where it
could not be, the comment was corrected instead.

Critical — a console session cookie alone reached code execution: every
pairing route rode the generic catch-all with the operator's admin bearer
attached. Arming, approving and PIN submission now sit behind the console
password like the other trust-root routes, and the armed PIN is returned
once in that gated response instead of riding a 1 s status poll.

High — the plugin lane no longer reads the unredacted log ring (which
carried the webhook credentials the /hooks carve-out exists to withhold);
hook lines log an origin and a short id, never a URL or a command line; a
plugin-reported pid is held to procscan's start-time floor before the
SYSTEM host will signal it; ClipOffer is gated on the live grant mask, so a
revoked guest loses the host clipboard in both directions; ENet refuses
connects with no live launch instead of letting LAN peers squat all four
slots; Windows secrets are born with their DACL applied rather than
world-readable; the sysext feed binds FEED and a monotonic SERIAL inside
the signed bytes; privileged_field allowlists the host-resolved launch
kinds so a new kind is privileged by default; five parser panics reachable
from one malformed NALU are range-checked; release-signing jobs pin bun,
sccache and actions by checksum/SHA; h2 -> 0.4.19 (RUSTSEC-2026-0258).

Deep links only auto-dial by stable record id now — a display name or an
address gets a confirmation on every client. The Apple identity key moves
to ThisDeviceOnly so it stops riding encrypted backups.

pf-vdisplay stops routing session identity through the process environment:
the injector backend threads through a typed slot, so per-batch getenv no
longer races a per-session setenv. The four remaining writes have no
in-repo readers and are documented as such; the SAFETY proof that claimed
ENV_LOCK made them sound is gone.

Verified: cargo clippy --workspace --all-targets --locked -D warnings and
cargo fmt --all --check clean in the CI image; web builds, tsc --noEmit
clean, 22/22 server tests; Swift debug+release + 26/26, Kotlin :kit 7/7.

Not fixed, deliberately: the plugin token can still mint command execution
(the plugin launch kind exists so a plugin names a command the host runs —
per-plugin identity does not change that, and the runner is one process
hosting fibers, so there is nowhere to hang a credential); the shared
plugin-UI origin; the rollback Authenticode publisher pin (Azure mints a
fresh leaf per request, and the signer subject is not in the tree). Each is
now described accurately where it lives instead of being claimed closed.
2026-08-26 09:22:36 +02:00
enricobuehler b64ac3cb32 fix(console-ui): the settings tabs answered only to shoulders — Up from the top row puts a TV remote's D-pad on the strip
A Chromecast field report: with only the TV remote — the pad had dropped off,
as Chromecast pads do — every settings tab but the first was unreachable. The
split of the one 30-row scroll into tabs put section switching on L1/R1, Tab
and PgUp/PgDn, and a remote has none of those; the D-pad's left/right already
mean 'adjust the focused row'.

Up from the list's top row now steps onto the tab strip instead of recoiling:
left/right travel the ring (wrapping, like the shoulders), Down or A drop
back into the rows of the tab that's showing, B still leaves the screen, and
the shoulders keep working from either focus. The strip shows the handoff —
brighter pill, ‹ › chevrons — the rows rest their focus ring while it holds
focus, and the hint bar names the D-pad's meaning up there. A pointer press
on the rows takes the focus back.

The gamepad path is untouched: nothing changes until an Up is pressed on the
top row, which used to be a boundary recoil.
2026-08-26 09:22:30 +02:00
enricobuehler f92b093f92 fix(kwin): KWin's stored setup darkened the desk and the restore never saw it — snapshot before create, re-assert after teardown
A Bazzite triple-monitor field report: monitors cycling off/on around every
session, sessions ending with some or all monitors dark or at the wrong
resolution, and an exclusive apply logging also_disabled=[] on a desk with
three enabled physicals.

KWin persists output state per MONITOR SET (kwinoutputconfig.json). Creating
our virtual output changes the set, and a set that ever ran exclusive has
'physicals disabled' stored — so KWin disables them ITSELF, in the window
between our output's creation and the topology apply's enumeration. The apply
then finds nothing enabled, captures nothing for restore, and teardown
re-enables nothing: the desk stays dark, and KWin's own recovery improvises
modes. Under Extend/Primary the same stored setup switched screens off that
those topologies promise never to touch.

Three moves, all in the KWin backend:

* create() snapshots the enabled physicals BEFORE the virtual output exists —
  the only read KWin's reaction cannot have polluted.
* Exclusive: the snapshot joins the restore list (union_restore) — an output
  KWin disabled before we could see it is still re-enabled at teardown.
  Extend/Primary: reenable_stranded() puts the stored-config casualties back
  on, immediately and once more after a short delay (KWin can apply the
  stored setup after our first read). One shot each, never a loop.
* The group-teardown restore gains a delayed verify that re-asserts AFTER our
  output is reclaimed — under the without-us set, so KWin persists the heal
  into the stored setup instead of re-fighting us next session. It stands
  down if a new session's managed output already owns the topology.

A box stranded BEFORE this fix stays stranded once: the snapshot cannot see
outputs that were already dark. Re-enabling them once in System Settings
while no stream is live writes the healthy without-us setup back, and the
loop cannot re-form.
2026-08-26 09:22:11 +02:00
enricobuehler b9adcc4897 fix(docs): the Fedora install line must name punktfunk-web, not hope for it
`punktfunk` only *Recommends* punktfunk-web (and punktfunk-scripting), so
`sudo dnf install punktfunk` installs the host with no console on any box where
weak deps are off — install_weak_deps=False, --setopt=install_weak_deps=0, or an
rpm-ostree layering that drops them. The user then follows step 4 and gets
"Unit punktfunk-web.service could not be found", with nothing saying why.

Name both subpackages in the install line at its single source
(data/platforms.json + the docs-site snapshot), which the guided installer and
the Fedora page quote verbatim, and drop the "the console comes with it" claim
from the README. Adds the symptom to Troubleshooting, including the other cause:
COPR has no punktfunk-web at all (no bun in its mock chroot).
2026-08-25 21:00:10 +02:00
enricobuehler d2c6e1c9c0 Merge pull request '0.31.4 — the screens that stayed lit, the power menu that did nothing, and the console no browser would open' (#394) from worktree-release-0314 into main
Reviewed-on: unom/punktfunk#394
2026-08-24 22:26:27 +00:00
enricobuehler 1d755ebeeb release: 0.31.4 — version bump, notes, CHANGELOG, Play notes
21 commits since v0.31.3 (14 non-merge). Cut from origin/main c407f6a6 (#393
merged).

THE NUMBER: a patch, and the cleanest one in a while — NOTHING versioned moves.
WIRE_VERSION 2, C ABI 25 with include/punktfunk_core.h showing NO diff against
the v0.31.3 tag (nor against v0.31.2 or v0.31.1 — third release running), driver
protocol 6 / min 3 with pf-driver-proto unchanged, gamepad channel 3, plugin
index schema 1, host event schema 1 (events.rs no diff), gamescope +pfhdr8 with
no new patch files, SDK 0.1.6 and plugin-kit 0.4.4 both untouched. api/openapi.json
is CONTENT-identical — info.version is the entire diff, the first release since
v0.31.0 where the management API grows nothing. No lockfile moves, in any of the
five. Four feat commits, all additive and all client- or Linux-host-side; v0.31.3
and v0.31.1 each carried two feats as a patch on the same reasoning.

THE SHAPE: Linux-host, and the faults share a premise — a policy written against
ONE desktop that silently did nothing on the others. Topology::Exclusive on a
gamescope session was implemented as org_kde_kwin_dpms and reached only a live
KDE desktop, so a Game Mode box (the deployment whose TV the operator most wants
dark) got nothing: #389 is five commits closing that — a DRM floor for a box with
no desktop at all, sway and Hyprland arms, the managed route's own hold, and the
box-session free that was gated on is_steam_launch. The sleep:idle BLOCK
inhibitor refused every Suspend() for a stream's length including the operator's
own from Steam's power menu, and on a DM-stopping takeover polkit's allow_active
fallback found no session to elect and dropped all three power actions to
auth_admin_keep (#391). The web console served the LEGACY identity because every
launcher names that pair and none of them CAN choose, so browsers rejected it and
the tray's mgmt-pinned probe refused the handshake and labelled a healthy console
dead (#393). Plus the client's encode down-driver (#392), an installTap format
snapshot reaching the ObjC terminate handler (#387), the console's bitrate row
(#388), and a GTK front door for the console UI (#390).

TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
  * The sway arm of #389 is NOT verified on glass — the lab has Bazzite, Nobara
    and SteamOS, no sway box. Hyprland, KDE and the DRM floor were all measured
    on real machines. The notes say so in "Thanks" rather than burying it.
  * #389's Hyprland arm shipped WRONG in its first cut and was corrected on glass
    (540e282e): the dpms dispatcher is a TOGGLE that ignores the state word, so
    the blind "send off, later send on" would have LIT an already-dark head at
    stream start and DARKENED a lit one at teardown — the exact failure the
    feature exists to prevent. Read -> act only if different -> verify.

DOCS FRESHNESS, per docs/releases/README.md step 1: #390 and #393 carried their
own docs-site updates (clients.md, and #389's two gamescope.md edits). Two facts
were left owed, both in gamescope.md and both the "stale sentence" class that
docs-drift cannot catch:
  * the darkening mechanism list still read "KDE, else DRM" and never learned
    about the sway and Hyprland arms from e0a82201;
  * the "could not deliver a dark screen" example was a box in Game Mode, which
    is now precisely the case that IS served by the DRM floor. The real remaining
    gap is GNOME/Mutter, so it says that instead.
Also: the punktfunk group grew a third grant this cycle (the three logind power
actions, via 49-punktfunk-power.rules), and the group's own callout block
enumerated only the usbip nodes — one sentence added there.
No new PUNKTFUNK_* variable this cycle (PUNKTFUNK_UI_TLS_CERT/KEY are
pre-existing and already in the baseline), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.

VERIFIED HERE: scripts/ci/check-docs-drift.sh clean, and PROVEN LIVE rather than
assumed — a deliberate one-byte break of the openapi copy makes it fail, and it
passes again restored; scripts/ci/check-docs-links.sh clean; the android.yml Play
notes gate run verbatim, 481/500 characters and unique against every other
release's file; both openapi copies cmp identical and stamped 0.31.4; cargo fmt
--all --check clean; git diff v0.31.3..HEAD empty on include/punktfunk_core.h, on
crates/pf-driver-proto, on punktfunk-core/src/lib.rs, on punktfunk-host/src/events.rs
and on api/openapi.json, which is the direct evidence for those five version rows;
git diff v0.31.1..HEAD on the C header also empty, which is the "third release
running" claim; Cargo.lock's 36 workspace version strings moved with Cargo.toml
and zero 0.31.3 strings remain; 27 crate dirs / 39 workspace members, both
unchanged; notes voice scan clean (zero backticked terms above ## For developers,
and zero internal names on a scan for gamescope/polkit/logind/DPMS/CRTC/DRM/
systemd/kwin/mutter/wlroots/rustls/ioctl); CHANGELOG link pinned to
src/tag/v0.31.4.

NOT RUN HERE, and why: any punktfunk-host build, clippy or cargo test — the host
does not compile on macOS at all, and CI covers it; the web/ and docs-site/ bun
builds — nothing under web/ is touched by this commit and the docs-site edits are
prose in an existing .md; the Android unit tests — nothing here touches Kotlin.

LEFT AS A DECISION, not made here: the tag itself. Notes, CHANGELOG and Play text
are authored ahead of it exactly as docs/releases/README.md step 1 requires, so
whichever workflow wins the create race seeds a complete release body — but
pushing v0.31.4 fans out every build workflow, and that is step 2.
2026-08-24 23:34:38 +02:00
enricobuehler c407f6a6d9 The console served the identity nothing pins, and the tray called it dead (#393)
The web console never followed the identity split: every launcher names the legacy cert.pem/key.pem, so it served a CN-only, SAN-less RSA cert. Browsers reject that outright, and the tray's liveness probe — pinned to the native identity the mgmt API serves — could never complete the handshake, labelling a healthy console "not responding" beside a tooltip reading "idle".

Resolved at the Nitro entry, the one place every launcher routes through: prefer the native pair when both halves are present, usable and in the same directory; fall back to the legacy pair otherwise. The tray's console probe drops its pin rather than gaining a second one, and `serve` now mints the native identity before the legacy one.

Reviewed adversarially (no blockers; the one real finding — a pair that could be assembled from two different directories — fixed in 1e2b956d). All 8 checks green, including the native-only clippy gate. Verified end to end against a built server: both pairs → P-256 with SANs; legacy only → RSA as before; 0-byte native cert → clean fallback rather than a restart loop.
2026-08-24 20:55:06 +00:00
enricobuehler 1e2b956de6 fix(web,tray,host): review follow-ups — pair the halves, and stop two comments overclaiming
Review of the parent commit. One real defect, the rest accuracy.

The resolver could hand back a MISMATCHED pair, which is the one invariant its
own header promised it never would: `nativeCert` came from `dirname(cert)` and
`nativeKey` from `dirname(key)`, two independent directories, so
("/a/cert.pem", "/b/key.pem") resolved to /a/native-cert.pem + /b/native-key.pem
— two unrelated files presented as a pair. No shipped launcher splits them, but
the guard is one comparison and it is the whole point of the module.

Rewritten off a suffix test instead of `node:path`, which fixes two more things
in passing. `node:path` resolves per-RUNTIME, so a POSIX CI runner reads
`C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and
Windows, where windows/service.rs hands us exactly that, is the platform the CI
job can never exercise. The suffix test gives the same answer everywhere and is
now covered by a win32 case. It also leaves the prefix VERBATIM, where
`join(dirname(p), …)` normalised /a/b/../cert.pem into a different directory the
moment `b` was a symlink.

Existence is no longer enough: `pf_paths::write_secret_file` is
create+truncate+write rather than temp+rename, so a console starting mid-write
could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart. Not
every launcher retries forever — the Steam Deck unit is `Restart=on-failure`
under the default rate limit, i.e. permanently dead. The check is now a
non-empty stat, mirroring the host's own `!c.trim().is_empty()`. Verified: with
native-cert.pem truncated to 0 bytes the console starts and serves the legacy
pair.

Two comments of mine overclaimed and are corrected rather than left to mislead:

  * serve() said "cert.pem existing implies the native pair does too". False on
    an upgraded host whose native clients pinned the legacy cert — load_or_adopt
    returns it and writes no native files at all. The ordering claim that IS
    true is narrower: whenever that call writes a native pair, it does so before
    cert.pem appears.
  * the tray said the console entry "always opens the same URL regardless of the
    probe". True of the menu entry, but win.rs gates the tray-icon single-click
    on console_up. Also notes that the Windows probe was never pinned to begin
    with (punktfunk_config_dir is None off Linux), so that half is a no-op.

Rest is doc drift the parent commit annotated in two launchers but not the other
four: web.env.example, README, web-run.cmd, and the ci.yml comment that still
said the web test step was "Scoped to server/".

18/18 web tests (was 14), biome and rustfmt clean, and the runtime check re-run
against a fresh build: both pairs -> P-256 with SANs; 0-byte native cert ->
legacy RSA, console still serving.
2026-08-24 22:45:47 +02:00
enricobuehler 49b5ffa2d8 fix(web,tray,host): the console served the identity nothing pins, and the tray called it dead
A Linux operator saw "Open web console (not responding)" in the tray next to a
tooltip reading "idle", and the console would not load in a browser either.

The host has kept two identities since the identity split (crate::identity):
native-cert.pem/native-key.pem (P-256, real SANs — what the native QUIC plane,
the mgmt API and every native client pin) and the legacy cert.pem/key.pem (RSA,
CN=punktfunk, NO SAN, kept byte-stable for Moonlight). The web console never
followed the split. Every launcher — the systemd unit, the NixOS module, the
Windows service supervisor, web-run.cmd, the Steam Deck installer — still names
the LEGACY pair, and none of them CAN choose: `Environment=` has no "this file,
else that one". So the console served a certificate with no SAN at all, which
costs twice over:

  * browsers reject a CN-only cert outright (ERR_CERT_COMMON_NAME_INVALID), so
    the console the operator was told to open does not load;
  * the tray's loopback liveness probe reused the agent PINNED to the mgmt
    identity — the native cert — so rustls refused the handshake and a perfectly
    healthy console was labelled "not responding". The "idle" tooltip beside it
    is the proof: the same agent reached mgmt fine on the very same tick.

The entry is the one place every launcher routes through, so the choice is made
there: prefer the native sibling pair when both files exist, as a PAIR or not at
all (a native cert with the legacy key completes no handshake with anyone). A
host that never took the split has no native pair on disk and falls through
unchanged, as does an operator-supplied cert under any other name. This also
hands the bundled bun the smaller secret: on a default build key.pem is the
Moonlight pairing SIGNING key, native-key.pem is only a TLS key.

The tray's console probe loses its pin rather than gaining a second one. It is a
different server and there is no rule that it presents the mgmt certificate — an
operator fronting the console with their own LAN-CA cert would have hit this just
as squarely. The probe sends no credentials, reads no body, and decides only a
menu label, so there is nothing for a pin to protect.

`serve` now resolves the native identity BEFORE minting the legacy one. That
closes a first-run window where the console (which waits on cert.pem) could start
between the two writes and serve the SAN-less cert for the rest of the boot, and
it fixes a second latent fault: with cert.pem missing but native clients paired,
the old order let load_or_create mint a brand-new cert.pem that load_or_adopt
then adopted while logging that it was preserving their pins.

Verified against the built server: configured exactly as the shipped unit does
(PUNKTFUNK_UI_TLS_CERT=.../cert.pem), it now serves the P-256 cert with
DNS:localhost/IP:127.0.0.1; with the native pair removed it serves the RSA cert
as before. 14/14 web tests pass, biome and rustfmt clean.
2026-08-24 20:30:25 +02:00
enricobuehler d65b9f3b1b Merge pull request 'Exclusive topology left the box's own screen lit — honor it on every desktop we drive' (#389) from worktree-gamescope-exclusive-panel-off into main 2026-08-24 16:06:39 +00:00
enricobuehler cd0b53f8fe Merge pull request 'The encode down-driver stands down when the rate isn't the lever' (#392) from worktree-abr-encode-downdriver into main
Reviewed-on: unom/punktfunk#392
2026-08-24 16:06:13 +00:00
enricobuehler 00a9d16201 fix(abr): the encode stand-down re-probes instead of lasting the session
Nothing this controller learns from evidence is permanent — both learned caps
re-probe on the CAP_REPROBE_WINDOWS_MIN ladder, and the clock-flush detector was
itself changed from "off for the rest of the session" to re-armable for exactly
this reason. The encode stand-down had no business being the exception.

What it answers is transient by nature: contention on the host's GPU ends when
the game exits to a menu, when the shader storm finishes, when the second app on
the card closes. What it silences is the only signal that can descend when the
encoder is genuinely past its compute knee on a link that shows nothing else. So
a permanent stand-down trades a bounded problem for an unbounded one — one
contended stretch would strip knee protection from every calm minute that
follows, including the ones where a climb can reach a rate the ASIC cannot hold.

A clean run now re-arms it, and the interval doubles each time the silence is
immediately re-earned, so a genuinely standing contention settles into a slow
poll rather than thrashing. The asymmetry is the whole argument: a too-eager
re-arm costs one ×0.7 and stands down again; a too-permanent silence costs the
protection outright.
2026-08-24 15:52:17 +02:00
enricobuehler 540e282e60 fix(hyprland): its dpms dispatcher is a TOGGLE, and the classic argv does not parse under Lua
Verified on the NixOS VM (125, Hyprland 0.55.4) — and the arm I shipped for it in
the previous commit was wrong twice over. This is why it went on glass.

**The argv did not work at all.** `hyprctl dispatch dpms off <name>`, the form
this file's own probe notes use, dies on the Lua config manager: `dispatch` is
shorthand for `hl.dispatch(...)`, so the bare words are parsed as a Lua
expression —

    error: [string "return hl.dispatch(dpms off HDMI-A-1)"]:1:
           ')' expected near 'off'

The Lua spelling is `hl.dsp.dpms("off", "<name>")` (found by enumerating
`hl.dsp` through `hyprctl eval`, which only exists on that manager). A hyprlang
box wants the classic form, there is no stable probe for which manager is
loaded, and `hyprctl_dispatch` already catches the exit-0 rejections both
produce — so try classic, then Lua, and report both failures if neither lands.

**And the dispatcher is a TOGGLE that ignores the state word.** Measured:

    On  ==[ hl.dsp.dpms("on",  "HDMI-A-1") ]==>  Off   <- asked ON, got OFF
    Off ==[ hl.dsp.dpms("on",  "HDMI-A-1") ]==>  On
    Off ==[ hl.dsp.dpms{state="off", ...}  ]==>  On    <- asked OFF, got ON

Both spellings, positional and table. So the blind "send off, later send on" the
previous commit shipped would LIGHT an already-dark head at stream start and
DARKEN a lit one at teardown — the operator's screen left off after the stream,
precisely the failure this policy exists to prevent. (It bit me while probing:
a restore fired at an already-On head turned it off, which for a while looked
like "dpms on cannot restore on Hyprland".)

So `dpms_one` is read → act only if the state differs → verify, via
`hyprctl -j monitors all`'s `dpmsStatus` (measured to track the connector's
sysfs `dpms` exactly, in both states, and a dark monitor stays listed). That
shape is also correct where the call really is a set, so it is not conditional
on detecting the manager. It returns whether it CHANGED anything, and
`dpms_other_heads` records only those — a head already in the wanted state is
left alone, because "fixing" it would break it, and reporting it would have the
re-light toggle a head we never darkened.

The on-glass assertion is relaxed from "every connected head goes dark" to "at
least one did, and all are restored": this VM carries a virtio `Virtual-1`
beside the real `HDMI-A-1` and Hyprland manages only the latter, so the strict
form failed on a difference that is not a defect.

Verified end to end, real Rust through the real dispatcher on a live Hyprland:

    before: [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
    during: [("card0-HDMI-A-1", "Off"), ("card1-Virtual-1", "On")]
    after:  [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
    went dark: ["card0-HDMI-A-1"]
    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok

The unmanaged `Virtual-1` is correctly untouched, and the test exercises the
double-acquire (reconnect) path too, so the toggle-safe release is covered.

cargo test -p pf-vdisplay 255 passed / 0 failed; clippy --all-targets clean;
fmt --check and check-docs-drift.sh clean. sway remains the one arm not verified
on glass — there is no sway box in the lab.
2026-08-24 15:29:24 +02:00
enricobuehler 7246f0fe60 fix(abr): the host-encode down-driver must stand down when the rate isn't the lever
A 1440p60 Lutris session ratcheted 57 → 5 Mbps over ten minutes on a link that
never dropped a packet — no keyframe asks, no flushes, a flat decoder, and the
host granting every rate it was asked for. Two things did it, both in the
client's encode down-driver.

The thresholds were absolute durations calibrated at 120 Hz ("~half a frame
budget", "≈1.5 × a frame budget"). At 60 Hz one frame is 16.7 ms, so an ordinary
one-frame encode hiccup cleared the SEVERE tier and took the immediate ×0.7
where the same hiccup at 120 Hz (8.3 ms) never reached it — which is exactly the
asymmetry the field log shows against 1440p120 sessions on the same host and
client. Size them in the session's own frame budget instead, plumbed from the
negotiated refresh and re-read on a mode switch.

And the signal fired for a cause the rate cannot fix. It exists to find the
encoder's compute knee, where cutting the rate cuts the work; when encode time
is held up by a game saturating the GPU that premise is false, the backoff
changes nothing, and `on_ack`'s baseline re-seed erases the evidence that
nothing improved. So remember the level each encode-attributed backoff fires at:
two in a row that fire no lower mean the rate is not the lever, and the signal
disarms for the session. Same shape as NOOP_CLOCK_FLUSHES_TO_DISARM. Loss, OWD,
decode and keyframe signals keep their full power throughout, and the host's own
climb refusal stays the backstop for a genuine knee.
2026-08-24 15:28:54 +02:00
enricobuehler e0a822016f feat(vdisplay): honor exclusive on sway and Hyprland too, not just KDE
`exclusive` on a gamescope session meant "turn the box's own panels off", and
that only actually happened on two kinds of box: one running KDE (via
`org_kde_kwin_dpms`) and one running no desktop at all (via the DRM floor added
in the previous commit). A gamescope session on a **sway** or **Hyprland**
desktop got nothing — the desktop keeps driving its panels, and the DRM floor
cannot help because that compositor holds DRM master, so `SET_MASTER` is refused.

There is no cross-compositor DPMS protocol, so the module becomes a dispatcher,
each arm self-gating on its own IPC so a box only pays for the one that answers:

| desktop        | mechanism                                              |
|----------------|--------------------------------------------------------|
| KDE / KWin     | in-process `org_kde_kwin_dpms`, then `kscreen-doctor`   |
| sway (wlroots) | `swaymsg output <name> dpms off`                        |
| Hyprland       | `hyprctl dispatch dpms off <name>`                      |
| none at all    | `drm_dpms` — the CRTCs off over DRM                     |
| GNOME / Mutter | cannot be served (below)                                |

**`kwin_dpms` is renamed `panel_dpms`.** A module that darkens sway and Hyprland
should not be named after KWin, and this file has already paid for one stale
name: the comment authorising the DPMS skip on the other gamescope routes ("its
takeover already stopped the desktop") outlived the stop it named by three
commits and hid the whole defect. (The rename's sed also caught
`org_kde_kwin_dpms` itself — the vendored protocol's interface names, which are
not ours to rename — and those are restored.)

**DPMS, not the `disable` these backends already have.** Both wlroots and
Hyprland implement their OWN exclusive topology by disabling heads; that is
right when the streamed output lives on that compositor and has to become "the
desk", and wrong here — a gamescope spawn owns no output there, so disabling
would move the operator's workspaces and re-home their windows for a stream that
is not even on their compositor. It matters most on Hyprland, where this
module's own notes record that a disabled head has no clean undo: the only known
restore is re-reading the operator's whole config, which drops every runtime
override they set by hand. DPMS is a separate axis and round-trips cleanly.

Both arms reuse their backend's existing `heads_to_disable` filter with an EMPTY
`ours` — a gamescope session owns nothing to spare, but a concurrent wlroots
session's `HEADLESS-*` still must be spared or darkening blacks out that client's
stream. They address heads by NAME and report which ones actually changed, so the
re-light undoes exactly those.

⚠ **GNOME is a structural gap, not an omission.** Mutter exposes no DPMS to
clients; its own `exclusive` mechanism (`ApplyMonitorsConfig` omitting the
physicals) needs a virtual output of its own to keep enabled, which a gamescope
session does not have; and the DRM floor is refused because Mutter holds master.
The `warn!` now names that instead of saying nothing.

⚠ **The sway and Hyprland arms are NOT verified on glass.** The lab has Bazzite,
Nobara and SteamOS — no sway or Hyprland box. Their argv shapes and the
sibling-sparing filter are unit-tested and everything compiles clean, but the
commands themselves have not been run against a live compositor. Treat them as
unproven until someone streams from one.

Verified: `cargo test -p pf-vdisplay` 254 passed / 0 failed; clippy
--all-targets clean; fmt --check and check-docs-drift.sh clean. Re-ran BOTH
on-glass tests on the Nobara VM after the rename — the managed path falls
through KDE → sway → Hyprland → DRM correctly on a box with none of the first
three, and the panel still goes On → Off → On:

    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok
    test drm_dpms::tests::live_the_panels_go_dark_and_come_back ... ok
2026-08-24 15:07:03 +02:00
enricobuehler b6938a9890 fix(gamescope): the managed route honors exclusive too — it is the one that matters most
The previous commit left Managed without a darken hold and wrote down a reason:
its display reports `DisplayOwnership::SessionManaged`, and `registry::acquire`
returns for anything not `Owned` ABOVE its `take_topology_restore()` pickup, so a
hold registered the way the bare spawn does would never be released — and a panel
dark after every stream is worse than one left lit.

That reasoning was right and the conclusion was wrong. Managed is the recommended
route, it gives the best experience, and it is the ONLY way to serve a client its
own virtual output at its own mode — so it is the route where `exclusive` most
needs to mean something. "No release path on the registry" is an argument for
putting the release somewhere else, not for skipping the feature.

Somewhere else is `do_restore_tv_session`: the one teardown every managed path
funnels through, and already the home of the drop-in sweep for exactly this class
of reason ("must not leak past an early return"). The release goes at the very
TOP of it rather than beside that sweep, because the SteamOS branch returns above
it. Safe there: releasing re-lights, and every path below either hands the box
back or deliberately keeps a headless session on a box with no connected display
— nothing lit to darken in that case anyway.

The hold is a bool, not a count, and that is load-bearing. The managed SESSION is
what gets darkened, not each connect: it outlives client disconnects (the point
of `MANAGED_SESSION`) and a same-mode reconnect reuses it warm without a
relaunch. Acquiring per connect would ratchet `kwin_dpms`'s refcount up with no
matching releases and pin the operator's panel dark for the rest of the host's
life. The edges are split into pure `managed_darken_{acquire,release}_edge` — the
same shape as `kwin_dpms::Holds` and for the same reason — so the balance rule is
unit-tested without a live compositor: reconnect takes no second hold, the
unconditional release is idempotent, a full cycle re-arms, and `extend` /
`SharedDesktop` still take no hold at all.

Verified on the Nobara VM (123, Fedora 44) in the takeover state, against a real
connected head, driving the actual managed helpers rather than the pure edges:

    before: [("card0-HDMI-A-1", "On")]
    during: [("card0-HDMI-A-1", "Off")]     <- incl. a second acquire (reconnect)
    after:  [("card0-HDMI-A-1", "On")]
    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok

The bare-spawn on-glass test still passes alongside it. (`mutter::live_mutter_
create_drop` fails on that box and is unrelated — it needs a GNOME session.)

`gamescope.md`'s per-model table said Managed does not darken a desktop beside
it; it now says what both owning routes actually do, and that neither needs root.

cargo test -p pf-vdisplay 252 passed / 0 failed; clippy --all-targets clean;
fmt --check and check-docs-drift.sh clean.
2026-08-24 14:48:07 +02:00
enricobuehler faf94087c5 feat(vdisplay): turn the box's own panel off over DRM, for a box with no desktop to ask
`kwin_dpms` asks KWin for DPMS-off, which is the right answer whenever there is a
KDE desktop to ask. A box sitting in Game Mode has none — it runs gamescope and
no KWin — and that is *precisely* the deployment whose TV the operator wants
dark. So `exclusive` silently did nothing there, which is the field report.

Measured on the Nobara VM (123, Fedora 44) before writing any of this, because
the obvious cheaper answers all fail on real hardware:

* Getting the box's session out of the way is NOT enough. After the takeover
  idles it, `card0-HDMI-A-1` sits at `enabled=enabled dpms=On` indefinitely —
  when no client holds DRM master the kernel just keeps the CRTC configured.
  That refutes `gamescope.rs:1573` ("left the Deck in a headless session with its
  panel dark") as a general claim: that was a Deck's internal panel.
* `setterm --blank force` is unreachable: `/dev/tty1` is `crw------- root:tty`.
* The connector's `dpms` sysfs attribute is read-only even as root.
* `/sys/class/graphics/fb0/blank` DOES work (`dpms=Off`) — but needs root, and
  the takeover deliberately stopped needing privilege in 0.31.0. Not worth
  re-introducing a group requirement for.

What does work, unprivileged: `/dev/dri/cardN` carries a logind uaccess ACL for
whoever holds the active seat (`crw-rw----+`), and `SET_MASTER` succeeds while
nobody else is master — exactly the state the takeover has just produced. So
walk `GETRESOURCES`, `GETCRTC` each id, and `SETCRTC` with `fb_id=0,
mode_valid=0, count_connectors=0` on the ones actually driving something. The
connector goes `enabled=disabled dpms=Off`, the same end state `kscreen-doctor
--dpms off` reaches through KWin.

Two measured properties shrank the code. The darkness **survives DROP_MASTER**,
so mastership is handed straight back — a host still holding it would starve the
box's own gamescope when the restore relaunches its session, turning a dark panel
into a session that cannot start. And the re-light is **`close(fd)`**: the
kernel's last-close restores the console, so there is no saved mode to replay, no
restore that can half-fail, and crash safety is free (a dead host has its fds
closed by the kernel and the box lights up) — the same property `kwin_dpms` gets
from DPMS being non-persistent. Nothing to journal, nothing to sweep at startup.

`SET_MASTER` failing is also the natural guard for the Attach route: a card
already mastered by a live compositor is not ours, and darkening it would darken
the very picture being streamed.

Four ioctls, three `repr(C)` structs, one `unsafe` block. The ioctl numbers
encode their payload size (`0x40`, `0x68`), so the layouts are pinned by
`const _: () = assert!(...)` at compile time — a struct that drifts would have
the kernel read or write the wrong byte count on someone's TV — plus a test that
restates it greppably and pins the two field offsets the count/data-pass dance
depends on.

The on-glass test earned its place immediately. Everything else was green —
ABI tests, clippy, 251 unit tests, and a Python probe that had proven the ioctl
sequence itself — while the first Rust cut still left the panel lit: `darken_card`
closed its fd before returning and `darken` re-opened a fresh one to hold. That
close is the kernel's LAST close, which restores the console; the new fd held
nothing. It reported `darkened cards: ["card0"]` over a connector at
`enabled=enabled dpms=On`. The fd that does the work is now the fd that is held.

Verified on that VM with this exact binary, against a real connected head:

    before: [("card0-HDMI-A-1", "enabled", "On")]
    darkened cards: ["card0"]
    during: [("card0-HDMI-A-1", "disabled", "Off")]
    after:  [("card0-HDMI-A-1", "enabled", "On")]

cargo test -p pf-vdisplay 251 passed / 0 failed; clippy --all-targets clean
(incl. the workspace's undocumented_unsafe_blocks deny); fmt --check and
check-docs-drift.sh clean.
2026-08-24 13:53:54 +02:00
enricobuehler 46d9e0d20f Merge pull request 'The gamepad console had no door — only a command line' (#390) from worktree-console-ui-front-door into main 2026-08-24 10:15:53 +00:00
enricobuehler 8e8451ca0c Merge pull request 'Sleeping or shutting the host down during a stream works again' (#391) from worktree-host-power-during-stream into main 2026-08-24 09:52:59 +00:00
enricobuehler 0e1bab019c feat(clients/linux): the console UI gets a front door, in the shell and in the app grid
The gamepad console has shipped since the arch split, but on Linux the only way
in was `punktfunk-session --browse` (or `punktfunk-client --browse`, which execs
it) on a command line — no way to find a mode. The WinUI shell has had both doors
for a while; this is the GTK half.

Three entry points, one destination:

* A gamepad button in the hosts header (`input-gaming-symbolic`), left of the
  hamburger — the same placement WinUI gives it.
* "Console UI" in the main menu, so the mode has a searchable name and not just
  a tooltip.
* `io.unom.Punktfunk.Console.desktop` — a second launcher shipped by the deb,
  rpm, arch and flatpak packagings. It is what an app-grid search finds and what
  gets added to Steam as a non-Steam game. `Exec=punktfunk-client --browse
  --fullscreen` deliberately goes through the shell binary: that argv already
  execs the session, and it is the command flatpak's Exec rewrite expects.

The in-shell launch is a `gio::Subprocess` — `wait_check_async` lands the child's
exit on the GTK main loop with no thread and no channel, releases `busy`,
refreshes the host list (the console can pair hosts), and banners a non-zero
exit. That last part is also how a session built without its `ui` feature
surfaces: it prints "--browse needs the console UI" and exits non-zero.

Which is why the Nix build does NOT install the new desktop entry — it compiles
the session `--no-default-features`, so that launcher could only ever print an
error. Noted in packaging/nix/README.md next to the existing Skia caveat.
2026-08-24 11:41:58 +02:00
enricobuehler 7951d12b06 Merge pull request 'The console's bitrate row reaches 2 Gbps, steps finely at the bottom, and takes a typed rate' (#388) from worktree-bitrate-picker into main 2026-08-24 09:37:34 +00:00
enricobuehler 4690a166ca Merge pull request 'The mic tap's format snapshot could abort the whole app' (#387) from worktree-fix-mic-tap-format-abort into main 2026-08-24 09:32:26 +00:00
enricobuehler 8c628b4e6c fix(gamescope): exclusive topology left the box's own screen lit under a dedicated game session
Field report, Nobara, 2026-08-24: launching a game from the library with
`game_session=dedicated` and `topology: exclusive` left the operator's physical
screen on. Two independent causes, neither of which had a line in the log.

The first is ours and recent. `c2f5e91b` replaced the managed takeover's
display-manager STOP with an idle drop-in, deliberately, so the box keeps a DM
able to service the user's own "Switch to Desktop". That stop was also, as a
side effect, the only thing that took the box's own panel down. Nothing replaced
it, and the comment authorising the DPMS skip on this file's other routes — "its
takeover already stopped the desktop" — outlived the stop it named by three
commits, so the gap read as intentional. That comment is now the two reasons that
actually hold, written at the returns they belong to: Managed reports
`SessionManaged` and `registry::acquire` returns above its
`take_topology_restore()` pickup, so a hold taken there would never be released
(a panel left dark after every stream is worse than one left lit — `relight` is
written as "the last line of defence for a dark monitor" for that reason); and
Attach mirrors a gamescope that may be lighting the panel, so darkening it would
darken the picture being streamed.

The second is older and louder, and is what the report actually hit.
`stop_autologin_sessions` was reached only for a Steam launch — `is_steam_launch`
is `cmd.split_whitespace().next() == Some("steam")` — because freeing Steam's
single instance was the only requirement anyone had for it. But on a Game Mode
box that same session is the DRM master of the TV (`gamescope/heads.rs`), so a
NON-Steam library game left it running and the panel showed live Game Mode for
the whole stream. `exclusive` is a second, separate reason to want that session
out of the way, and it is now stated as one: `free_box_session_for_exclusive` is
pure and unit-tested over all four Steam x Exclusive combinations, including that
`extend` / the `SharedDesktop` preset ("never blank the real monitors") must
still leave the box strictly alone. Best-effort, unlike the Steam arm above it —
there it costs a dark screen, not the game, so it warns and continues.

Neither could be diagnosed from a log. `kwin_dpms::darken` is only ever reached
because the operator selected `exclusive`, so every decline is "you asked for
your screens off and they stayed on" — and the definitive not-KDE arm sat at
`debug!`. A box already in Game Mode has no KWin to answer DPMS at all, which is
precisely the box this fires on. It says so at `warn!` now, the same honesty
`relight` has always had for the dark direction.

`gamescope.md` claimed managed "deliberately takes the session over and blanks
the panel". It takes the session over; blanking is Topology's job and differs per
model, so the page now says which model can do what. The polkit/lingering block
above it is NOT stale — it is scoped to the restore path and pre-0.31.0 adoption,
and is left alone.

Verified: `cargo test -p pf-vdisplay` 249 passed / 0 failed, clippy --all-targets
clean, docs-drift gate clean. Not yet exercised against a live Nobara box — the
DPMS half cannot fire there by construction (no KWin in Game Mode), so what wants
measuring on the VM is whether releasing the DRM master leaves the connector dark
or merely blank-but-powered.
2026-08-24 11:23:45 +02:00
enricobuehler aef7f7877f feat(console-ui): the bitrate row reaches 2 Gbps, steps finely at the bottom, and takes a typed rate
The gamepad shell's Bitrate picker has been seven rungs ending at 80 Mbps since the
console shipped, which is the ceiling a user just ran into — the GTK dialog beside it
has always gone to 3000 Mbit/s, so the two surfaces disagreed about what this machine
may ask for, and the console was the smaller of the two.

Three changes, one row:

- The ladder is 30 rungs, 1 Mbps to 2 Gbps. Tight at the bottom (1, 2, 3, 4, 5, 6, 8,
  10, 12, 15, 20, 25 …), where one rung decides whether a thin link is watchable, and
  coarse at the top, where a rung is noise. Rates at or above a gigabit read as Gbps.
- Y opens a typed rate on that row — digits, four of them, through the tray keyboard
  (or SDL text input, and Steam's own keyboard on a Deck) exactly like the add-host and
  pair fields. A goes on cycling the ladder everywhere, so the console's grammar is
  unchanged; the field is what the ladder cannot be, which is every number in between.
- A rate that is not a rung now steps to its NEIGHBOUR. The generic picker snaps a value
  it does not recognise to its first option, which on this row is Automatic: one nudge
  threw away a rate typed here or set by the desktop spinner.

The desktop dialog gets the same complaint's other half: its spinner steps 1 Mbit/s
instead of 5, so 3, 4 and 6 are reachable without typing.

`Screen::edit_key` now takes the context, because this is the first field that commits
into the settings store when it closes rather than holding text for a later action row.
2026-08-24 11:19:45 +02:00
enricobuehler f60b6e30e2 fix(clients/apple): the mic tap's format snapshot could abort the whole app
`installTap(onBus:bufferSize:format:)` validates a non-nil format against the
bus and raises an Objective-C exception on ANY mismatch. Swift cannot catch
that, so it reached the terminate handler and aborted the process — SIGABRT in
`AVAudioEngineGraph::InstallTapOnNode`, crashing macOS 0.31.0 at session start,
not at launch.

The format handed to the tap is necessarily read a moment earlier
(`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath
it in that window: a device switch, a clock/rate change, or the
`kAudioOutputUnitProperty_CurrentDevice` swap `startCapture` itself performs two
lines before. The existing guard only rejected the 0 Hz / 0-channel case, which
is a different failure — a device that is absent, not one that changed.

Install with `format: nil` instead, which is the documented "use the bus's own
format" and makes the mismatch unrepresentable rather than merely unlikely.
The tap then has to follow the real format, so the rate-dependent pieces (mono
bus, resampler, both scratch buffers) move into a `MicChain` the tap rebuilds
when `buffer.format.sampleRate` differs — a chain pinned to a stale rate would
resample by the wrong ratio and pitch-shift the mic. That rebuild subsumes the
old grow-on-larger-quantum branch; the steady state still allocates nothing.

Tests cover the sizing arithmetic the rebuild rests on — including that
`staging` fits the UPWARD ratio, which silently truncates every packet on any
device below 48 kHz if it is sized for the input rate instead.
2026-08-24 11:16:07 +02:00
enricobuehler 4d155f4985 fix(packaging): a takeover that stops the display manager keeps Steam's power menu working
Second half of "the power menu does nothing during a stream", and an
independent fault from the sleep veto: on the takeover flavors that STOP
the display manager, the box is left with no active local session, and
that is what logind's power actions are gated on.

logind ships `power-off`/`reboot`/`suspend` as `allow_active: yes`.
polkit decides "active" from the caller's own logind session and, for a
caller that has none — every `systemd --user` unit, which is exactly what
the managed gamescope session is — falls back to the user's elected
DISPLAY session. logind elects that only from `user`/`greeter` class
sessions, never from the user manager's own, so stopping the display
manager removes the last candidate and all three actions drop to
`auth_admin_keep`: an interactive password prompt, put to a
non-interactive caller, on a screen that is switched off.

On SteamOS-like boxes that is the call being refused. Steam does not ask
logind for "Shut Down" at all — it writes $STEAMOS_STEAM_SHUTDOWN_SENTINEL
and exits, and gamescope-session-plus runs a plain `poweroff` once Steam
is gone. During a stream that wrapper is ours, in the session-less
transient unit.

Measured on Bazzite, 2026-08-24: the identical
`pkcheck --action-id org.freedesktop.login1.power-off` from a
`systemd --user` unit answers authorized with sddm up, and
`auth_admin_keep` with sddm stopped — and answers authorized again, in
that same stopped state, with this rule installed.

Scope it to the (shipped-empty) `punktfunk` group, which is the same
group the takeover's own root helper authorizes on: a takeover that stops
a display manager cannot work without that helper, so this grants to
exactly the population the fault reaches. The three actions are the three
entries in Steam's power menu; the `-multiple-sessions` and
`-ignore-inhibit` variants are deliberately left out.
2026-08-24 11:00:24 +02:00
enricobuehler 4b5f0dac6b fix(host): a stream no longer vetoes the box's own sleep
Since b7a00137 the host has held a logind `sleep:idle` inhibitor in BLOCK
mode for the whole length of a stream, so a passive (video-only) viewer
could not have the box suspend out from under them. A block lock on
`sleep` refuses EVERY suspend, though, not just the one an idle timer
asks for: "Sleep" in Steam's Big Picture power menu reaches logind as the
same `Suspend()` call, and logind answers it with

    Operation inhibited by "Punktfunk" (PID …), reason is "a client is streaming".

Nothing in that UI surfaces a D-Bus error, so the menu entry simply does
nothing — for as long as anyone is watching the box. Reproduced on a
Bazzite host, 2026-08-24, by taking the same lock by hand.

Hold the veto only while the stream is QUIET. Any client input on either
plane drops it synchronously — releasing is a close(2) on the inhibitor
fd, no round trip, so a Sleep press cannot race it — and it is re-taken
only after 30 s of silence. That is the line the original justification
already drew ("remote INPUT resets the compositor's idle timers, but a
video-only viewer sends none"): a person choosing Sleep is, by
definition, sending input, and a passive viewer never does. The case the
lock was written for is unchanged — 30 s of nothing and the veto is back.

Both planes stamp the clock at their single input entry point: the native
input thread's channel recv (before the grant tests — a denied event
still means a person is there) and the GameStream control plane's three
decode arms, past the keepalive gate, because a keepalive is the one
thing a passive viewer does send.
2026-08-24 11:00:08 +02:00
enricobuehler 5e30805490 Merge pull request '0.31.3 — the launch that dropped, the refresh a TV never output, and the probe that choked the link' (#386) from worktree-release-0313-recut into main 2026-08-23 10:32:42 +00:00
enricobuehler 7312f0ddba chore(sdk): cut 0.1.6 — the rename route's types cannot reach a plugin until they ship
The v0.31.3 CHANGELOG recorded this cut as a decision left open, on the same
reasoning v0.31.0 used for 0.1.5: a plugin resolves `@punktfunk/host` from the
registry, so types sitting in `sdk/` reach nobody until a version carries them.
#374 added `PATCH /clients/{fingerprint}`, `RenameClient` and
`PairedClient.label` to the management API and regenerated the client for them —
so without this cut the route exists on every 0.31.3 host and no plugin can call
it in a typed way.

ONE FILE is the whole diff since sdk-v0.1.5: `sdk/src/gen/punktfunk.ts`. It is
larger than the feature because regenerating it from the UNCHANGED committed spec
already produced a ~700-line diff — the checked-in copy had drifted from its own
pinned generator, and nothing in CI regenerates or verifies it (unlike
api/openapi.json and include/punktfunk_core.h, which are both gated). #374 landed
the clean regeneration rather than hand-patching generated code, and this cut
publishes it.

`SDK_VERSION` moves with `package.json`. It is a hand-maintained constant — the
build sets `rootDir: "src"` so it cannot import the manifest, and the runner ships
as one bundled `runner-cli.js` with no manifest beside it — and the runner
compares it against the SDK installed in the plugins tree to decide whether to
reinstall. Shipping 0.1.6 with the constant still reading 0.1.5 would publish the
types and then never deliver them. `version.test.ts` gates exactly that, which is
also what sdk-publish.yml's "Tag matches package version" step re-checks against
the tag.

GATES, all four steps sdk-publish.yml runs, in order and locally:
`bun install --frozen-lockfile --ignore-scripts` clean, `bun run typecheck`
clean, `bun test` 83 pass / 0 fail / 191 expect() calls across 12 files (the same
83 the 0.1.5 cut reported), `bun run build` clean. Nothing but the two version
sites and the two release documents is touched — no dist/ or lockfile churn
reached the tree.

`@punktfunk/plugin-kit` is deliberately NOT re-cut: nothing under plugin-kit/ has
moved since 0.4.4, which stays the registry's `latest`.

Tag `sdk-v0.1.6` on the merge commit, alongside `v0.31.3`. The two version
independently by design — sdk-publish.yml triggers on `sdk-v*` and the app's `v*`
tags never republish the SDK — so the shared commit is a convenience, not a
coupling.
2026-08-23 12:13:08 +02:00
enricobuehler e7ebaf591c release: 0.31.3 — version bump, notes, CHANGELOG, Play notes
41 commits since v0.31.2 (26 non-merge). Cut from origin/main f5931650 (#385
merged, main green).

THE NUMBER: a patch. One versioned surface moves and it moves additively — the
management API gains PATCH /clients/{fingerprint}, the RenameClient schema and
PairedClient.label, none of which existed before, so nothing that consumes the
API today changes shape. Everything else is where v0.31.2 left it: WIRE_VERSION
2, C ABI 25 with include/punktfunk_core.h showing NO diff against the v0.31.2 tag
(second release running), driver protocol 6 / min 3 with pf-driver-proto
unchanged, gamepad channel 3, plugin index schema 1, host event schema 1,
gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit 0.4.4 both
untouched. Two feat commits (#374, #384), both additive; v0.31.1 carried two
feats as a patch on the same reasoning.

THE SHAPE: the faults share a family resemblance — a session degrading or ending
against something ordinary that nothing was checking. Steam's pre-launch trees
latching the game lease and their exit then read as the game's (#372); a
fullscreen game mode-setting the virtual display under both stream loops (#373);
the forced-keyframe coalesce window measured in frames rather than time (#377);
an Android TV negotiating the refresh its own console pin installed rather than
what the panel outputs (#378); a startup capacity probe large enough to
black-hole the link it was measuring (#379); a hand-back that never verified the
panel came back (#375); a half-minted audio devnode nothing afterwards
recognised (#381); and a failed compositor build that unlinked the working one it
never replaced (#382). Plus two Android input/present fixes (#376, #380), the
console's per-frame cost and its new resolution switch (#384, #385), one feature
(#374), and CI (#370, #383).

TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
  * #375 ships WITHOUT a reproduction. Five scenarios across both distro families
    on real VMs all recovered cleanly and the first proposed mechanism was
    disproved on glass, so it closes the gap that lets any trigger end as a dark
    panel rather than guessing at one.
  * #380 is re-implemented from #371's diagnosis, and #371 is NOT merged. All
    three faults were real and correctly identified; each fix as sent reached
    further than the hardware that needed it. The notes credit the diagnosis.

DOCS FRESHNESS, per docs/releases/README.md step 1: #379, #380 and #384 carried
their own docs-site updates (configuration.md, input.md, client-settings.md). The
one fact left owed was naming a Moonlight device, whose canonical home is the
"Managing paired devices" section of docs-site/content/docs/pairing.md — a
paragraph goes there. No new PUNKTFUNK_* variable this cycle
(PUNKTFUNK_RECOVER_SESSION_CMD is pre-existing and already documented in
configuration.md and gamescope.md), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.

VERIFIED HERE: scripts/ci/check-docs-drift.sh clean; scripts/ci/check-docs-links.sh
clean; the android.yml Play notes gate run verbatim, 444/500 characters and unique
against every other release's file; both openapi copies cmp identical and stamped
0.31.3; cargo fmt --all --check clean; cargo audit clean over all five Rust
lockfiles (h2 fixed in the commit below this one); cargo about --fail clean on the
host workspace; git diff v0.31.2..HEAD on include/punktfunk_core.h and on
crates/pf-driver-proto both empty, which is the direct evidence for those two
version rows; Cargo.lock's 36 workspace version strings moved with Cargo.toml;
notes voice scan clean (zero backticked terms above ## For developers) and the
CHANGELOG link pinned to src/tag/v0.31.3.

NOT RUN HERE, and why: any punktfunk-host build, clippy or cargo test — the host
does not compile on macOS at all, and CI covers it; the web/ and docs-site/ bun
builds — nothing under web/ is touched by this commit and the docs-site edit is
prose in an existing .md; the Android unit tests — nothing here touches Kotlin.

LEFT AS A DECISION, not made here: sdk/src/gen/punktfunk.ts changed in #374 (a
clean regeneration that also absorbed ~700 lines of pre-existing drift) but
@punktfunk/host is not re-cut, so the registry's 0.1.5 has no types for the new
route. Cut sdk-v0.1.6 if anything outside this repo needs them.
2026-08-23 12:01:35 +02:00
enricobuehler 010949fead fix(deps): h2 0.4.15 -> 0.4.18, closing RUSTSEC-2026-0258
`cargo audit` on the root lockfile went red on 2026-08-17, when RUSTSEC-2026-0258
was disclosed against h2 <= 0.4.15 (unbounded empty DATA frames; fixed in
0.4.16). audit.yml's cargo-audit job is BLOCKING and fires on every Cargo.lock
change, so the 0.31.3 version bump in the next commit would have taken it red on
merge regardless of this advisory's own timing.

h2 is transitive — no manifest in the workspace declares it — so this is a
lockfile-only change.

MINIMAL ON PURPOSE. `cargo update -p h2` reports "Locking 1 package" but also
rewrote nine unrelated entries from `windows-sys 0.61.2` to 0.52.0/0.59.0,
gratuitous resolver drift that would have changed what the Windows builds compile
against for no reason. That was discarded; the two h2 lines are applied directly
instead, and `cargo metadata --locked` accepts the result with nothing else
moving — which is the proof the resolver needed none of the rest.

VERIFIED: `cargo audit` over all five Rust lockfiles. The root one is now clean;
the other four already were. The two lines cargo-audit still prints
(`audiopus_sys`, `paste`) are *unmaintained* warnings, already allowed via
.cargo/audit.toml, and do not fail the job.

NOT REGENERATED, deliberately: THIRD-PARTY-NOTICES.txt still records h2 0.4.15.
scripts/gen-third-party-notices.sh walks the dependency closure of the machine it
runs on, and on macOS it DROPS nine crates — the rusqlite / libsqlite3-sys /
fallible-iterator / hashlink cluster, 575 -> 566 — because they are gated to
platforms this Mac is not. Committing that would remove attributions a Linux or
Windows build genuinely links, which the script's own header calls a legal
regression rather than an untidiness. Regenerate on Linux. Nothing in
.gitea/workflows diffs the checked-in copy, and build-deb.sh /
pack-host-installer.ps1 / punktfunk.spec / pack-msix.ps1 each regenerate it on
their own platform, so the shipped packages are accurate and this is cosmetic
drift in the in-repo copy only.
2026-08-23 11:54:14 +02:00
enricobuehler f5931650e0 Merge pull request 'main is red: the Android-only row list never learned about the new resolution switch' (#385) from worktree-console-tv-perf-safe-fixes into main
Reviewed-on: unom/punktfunk#385
2026-08-23 09:51:00 +00:00
enricobuehler 519d004cab test(console-ui): the Android-only row list gains the new resolution switch
`platform_row_split_hides_only_the_other_platforms_concepts` pins the exact
ordered set of rows the desktop does not show, which is the point of it — a row
that silently changed platform is the regression it exists to catch. The new
switch is Android-only by design, so the expected list grows by one, between the
Controllers action row and the console-UI switch (it sits under Reduce motion,
earlier in the Interface tab than either).

Caught by CI on both the Linux and Windows legs, which run this crate's tests;
the row-COUNT assertion next to it was already updated and passed.
2026-08-23 11:43:10 +02:00
enricobuehler 46201fd9c3 Merge pull request 'The console redrew everything, every frame, at whatever resolution the panel handed it' (#384) from worktree-console-tv-perf-safe-fixes into main
Reviewed-on: unom/punktfunk#384
2026-08-23 09:33:41 +00:00
enricobuehler f320f4b465 feat(clients/android): "Reduce interface resolution", for the 4K boxes the console is slow on
The console draws at whatever resolution the panel hands it, and on a 4K
television or projector that is four times the fragment work of 1080p on a
graphics chip built to decode and composite video rather than to draw a moving
interface. The reporter's two devices — a Fire TV Stick 4K Max and a Valerion
projector — are both premium products and both exactly this shape: the money
is in the light engine and the panel, and the SoC is a TV part. A premium 4K
box is MORE likely to want this than a cheap 1080p stick, which never had the
extra pixels to begin with.

So: an off-by-default switch in the controller-optimized settings, directly
under Reduce motion, because the two are the same kind of bargain — give up
some fidelity, get a smoother console. On, the buffer's long edge is capped at
1920 with `SurfaceHolder.setFixedSize` and the compositor scales it up for
free. Text goes a little softer. Nothing else changes.

Two things this had to get right, neither of which is obvious from the call:

`setFixedSize` shrinks the BUFFER and not the VIEW. Everything that speaks in
surface pixels therefore has to be scaled to match — the safe-area insets, the
design-unit scale, and the pointer coordinates, which a mouse still reports in
view pixels and which would otherwise land the cursor at twice its true
offset. The scale factor is one number applied to both axes, so the aspect
ratio survives exactly and no layout can stretch.

And the buffer is sized from the SurfaceView's own laid-out size, reported
back through `onSizeChanged`, rather than from `displayMetrics`. The two
normally agree, but `displayMetrics` has a long history of disagreeing with a
view's real size by a system bar depending on the version and on who is
hiding what, and a buffer whose aspect ratio does not match the rect it is
scaled into is a stretched interface. "Normally agree" is not something to
hang picture geometry on.

The pointer listeners are installed in `AndroidView`'s `factory`, which runs
once, so the factor reaches them through `rememberUpdatedState` — captured
directly it would freeze at its first-composition value (1, before any layout
has reported a size) and a mouse would be wrong for the rest of the session.
The same reason `platformUp` is already held that way.

⚠ This is the INTERFACE only and shares nothing with the stream. Picture size
is `effectiveMode`, off `Display.mode.physicalWidth` — a physical display
mode, not any surface's buffer — and picture scaling is the separate
`renderScale`. The two `SurfaceView`s are different views and this is the only
`setFixedSize` call in the client. The name keeps "interface" in it, and the
docs entry ends by pointing at Resolution and Bitrate, so that nobody turns
this on expecting a sharper stream.
2026-08-23 11:21:41 +02:00
enricobuehler 89eb031cd6 A dropped skia download read as a lint failure, and had no retry to survive on (#383)
skia-bindings pulls ~19 MB of prebuilt Skia from inside its build script with a
bare curl and no retry, and swallows a failed download into a from-source Skia
build the CI containers cannot complete — so a dropped transfer surfaced as
"Clippy (Android target) failed" with the real cause 1,800 lines up.

A retrying curl shim first on PATH covers it (skia-bindings already resumes and
caches the part-file, so a retry continues the transfer). The prose rule in
android.yml's env block is now a gate that fails on STARTING A FULL BUILD.
2026-08-23 08:24:57 +00:00
enricobuehler 2991001fe4 perf(console-ui,clients/android): the console re-shaped every string and raised a no-op layer, every frame
A field report of a sluggish console UI on a Fire TV Stick 4K Max and a
Valerion projector. The Skia shell is faster than the Compose one it replaced
per unit of work; it was doing far more work than anyone had counted, and all
of it on every frame whether or not anything had changed.

Four costs, none of which change a pixel:

`Fonts::paragraph` built a `ParagraphBuilder`, added its text and called
`layout()` on every call — the whole shaper, HarfBuzz and line breaking and
font fallback, for every string on screen, sixty times a second. It is now
built once per distinct (text, shape, weight, size, width, colour) and kept.
Position is deliberately not in the key, so a shelf that scrolls and a screen
that slides both re-use what they already shaped. Cold entries are dropped
once the map passes its ceiling, by the two frames that last drew them, so the
live set is what is on screen and paging a large library cannot grow it
forever. The loose `(TextAlign, Option<usize>)` pair became a `Para` tag on
the way past: those two were never independent, and it is half of a hash key
now.

`LayerEnv::paint` raised an unbounded `save_layer` unconditionally — including
on the settled path, where alpha is 1, the scale is 1 and the slide is 0. That
allocates an offscreen the size of the whole SURFACE and composites it back,
to apply an alpha of one, on every frame the console sat still. Skia does not
elide it: `SkCanvas::saveLayerAlphaf` forwards alpha >= 1 straight to
`saveLayer(bounds, nullptr)`, whose only early-out is an empty clip. On a 4K
panel that is a 33 MB render target per frame, against a Skia budget that is
64 MB on a 2 GB box — so it was evicting real work to do nothing. Dropping it
is pixel-identical rather than close: nothing in this crate draws with a blend
mode other than `SrcOver`, `SrcOver` is associative, and there is no LCD
subpixel text to gain or lose an isolation. `screens::home` had already
learned this one tile-deep; this is the same fix one level up.

The toast's layer was unbounded too, for a 34 dp pill. Everything inside it is
inside the pill, so it takes the pill's rect and some slack for the hairline.

`draw_clipped` measured its ellipsis fit by allocating a `String` per
character, for every over-long title on screen, every frame. It measures out
of a stack buffer now. The controller chip's string stopped being rebuilt
sixty times a second to say the same thing.

On the Android host, the render thread now takes the same priority lift the
decode thread has taken all along (`-8`, a band below the stream's `-10`, so
the two do not compete when the console is up mid-session). At default nice, a
TV box's scheduler is free to park the console's frame loop on a little core
behind background work, which reads as a UI that lags the remote.

And the thing that made this hard to answer in the first place: the console
logged its GLES version and its cache budget and never its render resolution
or its frame cost, so "it feels sluggish" could not be triaged from a log
bundle at all. It now names the surface size when it wraps one, and reports
mean and peak draw time once a minute. The window is timed around the draw and
not the swap — `eglSwapBuffers` blocks on vsync, so wall-clock per iteration
is always the panel period and says nothing.

What is deliberately NOT here is the biggest single lever on a 4K box: capping
the console's render resolution. That is a real quality trade on a panel
someone bought for its resolution, and it is not this commit's to make.
2026-08-23 10:22:58 +02:00
enricobuehler 19df33e0f7 Merge pull request 'A failed gamescope rebuild took HDR from boxes whose compositor still worked' (#382) from worktree-gamescope-rebuild-keeps-hdr into main 2026-08-23 08:06:53 +00:00
enricobuehler c920204184 Two audio-endpoint bugs found chasing the Sound Recording-tab hang (#381)
A host that died mid-mint left an orphan devnode and the next start minted a
duplicate; the registry stamp route reached for the Render hive even for capture
endpoints. Both reproduced and verified on the .173 Windows lab box.

The Recording-tab hang that prompted the investigation is NOT fixed — it did not
reproduce on .173, and nine candidate mechanisms were ruled out by direct
measurement. See the PR body for the disproof table.
2026-08-23 08:05:05 +00:00
enricobuehler 6f4613e146 fix(ci): a dropped skia download read as a lint failure, and had no retry to survive on
scripts/ci/retry.sh already wraps every single-shot network call in CI, for the
reason documented there: the runner box runs many jobs in parallel and its
network sheds packets under that load. One of the largest fetches in this
workspace was never wrappable that way - skia-bindings pulls ~19 MB of prebuilt
Skia per target from INSIDE its build script, with a bare 'curl -sS -f -L' and
no retry (build_support/binary_cache/utils.rs).

Measured on main 2026-08-22, android job:

  DOWNLOAD AND INSTALL FAILED: curl error code: "18"
  curl stderr: "curl: (18) end of response with 17054400 bytes missing"

2 MB of 19,057,024 arrived before git.unom.io closed the connection; the same
asset pulls fine from a dev box. skia-bindings then swallowed it - its
try_prepare_download falls through to STARTING A FULL BUILD, a from-source Skia
build the CI containers carry no deps for - so the job surfaced as
'Clippy (Android target) failed' with a Gradle stack trace and the real cause
1,800 lines above it.

* A retrying curl shim first on PATH is the only lever that reaches inside a
  build script, and the cheapest correct one: skia-bindings already passes
  '-C -' and caches the part-file under OUT_DIR/.cache, so a retry CONTINUES
  the truncated transfer rather than restarting it. --retry-all-errors is
  load-bearing: a truncated transfer is not an HTTP status, so plain --retry
  would let error 18 through.
* Wired into android.yml and both ci.yml rust jobs - pf-console-ui pulls
  skia-safe too, so ci/rust downloads Skia on any target-cache miss.
* The rule android.yml's env block states in prose ('Every ABI's log must show
  DOWNLOAD AND INSTALL SUCCEEDED') is now a gate that fails the job on
  STARTING A FULL BUILD, so a dropped prebuilt can never masquerade as a lint
  failure again.
2026-08-23 10:00:21 +02:00
enricobuehler 3b08da11ff fix(gamescope): pin libdisplay-info to the vendored subproject, like wlroots
Caught on the SteamOS lab VM while verifying the previous commit end to end. With
libx11-xcb-dev added the build finally COMPLETED (652/652, banner "3.16.25-21-gb71a56c
+pfhdr8") — and then failed its on-glass check:

    punktfunk-gamescope: error while loading shared libraries:
    libdisplay-info.so.2: cannot open shared object file

Self-inflicted: the previous commit also took libdisplay-info-dev from the CI image's
list. gamescope vendors libdisplay-info as a submodule, but it is NOT in
force_fallback_for, so meson preferred the system lib the moment the build box had the
-dev package and linked it SHARED. SteamOS ships no libdisplay-info.so.2, so the binary
built, installed and printed its +pfhdr banner inside the distrobox and could not start
on the machine it exists for.

This is verbatim the wlroots trap the same comment block already documents ("starts fine
on the build host and dies with libwlroots-0.19.so ... anywhere else"), so it gets the
same remedy rather than a second one: libdisplay-info joins force_fallback_for. "Just
don't install the -dev package" does not hold — Debian, Fedora and Arch all have it and
anything can pull it in transitively, and the failure is silent right up to the on-glass
check that build-gamescope.sh happens to run.

Also drop libdisplay-info-dev from the Deck list (pointless once the fallback is pinned)
and record why that list must NOT be synced with ci/gamescope-trixie.Dockerfile: the CI
list targets a .deb that runs on Debian, this one cross-builds in trixie for SteamOS
glass. libx11-xcb-dev and libxkbcommon-x11-dev stay — SteamOS ships both sonames.

The on-glass check did its job here: it caught the bad binary, removed it and left the
box SDR rather than letting the host promise HDR it could not deliver.
2026-08-23 09:44:59 +02:00
enricobuehler 3ee88bb8cf Merge pull request 'acquireLatestImageAsync hands back a fence it already gave away' (#376) from worktree-asc-fdsan-acquire-fence into main
Reviewed-on: unom/punktfunk#376
2026-08-23 07:42:10 +00:00
enricobuehler 773eea24d9 Merge pull request 'A DualSense's buttons all reach the stream, and its Mute button works' (#380) from worktree-dualsense-buttons-and-pad-routing into main 2026-08-23 07:39:02 +00:00
enricobuehler 3b5c95959b Merge pull request 'The startup capacity probe stops black-holing constrained links' (#379) from worktree-abr-probe-target-from-stream-cap into main 2026-08-23 07:38:49 +00:00
enricobuehler a2bc9a2bdc fix(steamdeck): a failed gamescope rebuild took HDR from boxes whose compositor still worked
ROOT CAUSE of "HDR stopped working after updating to 0.31.2" on a Deck source install.
Two defects, one symptom.

1. scripts/steamdeck/build-gamescope.sh has been UNBUILDABLE since 2026-08-13, when
   3ac4548c turned `-Denable_gamescope_wsi_layer=true` on. The layer needs x11-xcb, which
   Debian splits into its own libx11-xcb-dev; the distrobox apt list — last touched
   2026-07-31 — never got it. MEASURED on debian:trixie against that list verbatim,
   gamescope at the pinned 5fb8dce4:

       Run-time dependency x11-xcb found: NO (tried pkgconfig and cmake)
       src/layer/meson.build:3:14: ERROR: Dependency "x11-xcb" not found

   `meson setup` exits 1 with the list as it was and 0 with libx11-xcb-dev added, and
   build-punktfunk-gamescope.sh treats a missing layer as a hard error, so the whole build
   fails. ci/gamescope-trixie.Dockerfile walked into the identical trap one release later
   (1b28a7f7, v0.28.1) and asserts x11-xcb at image build; this list never got the same
   fix. Debian-family only: Arch's libx11 and Fedora's libX11-devel carry x11-xcb.pc.
   xkbcommon-x11 and libdisplay-info measured absent too, and are added with it.

2. The build-failure branch then called `unwire`, deleting PUNKTFUNK_GAMESCOPE_BIN from
   host.env. A failed build REPLACED NOTHING — the previously installed binary is still on
   disk and still passes `verifies`. So a rebuild that never landed took HDR away from a
   box that had been streaming it minutes earlier. The script warns into a log nobody reads
   and exits 0, the update reports success, and the host then resolves the distro's stock
   /usr/bin/gamescope at patch level 0 and fixes the session at 8-bit SDR in the Welcome —
   which the punktfunk/1 handshake cannot take back.

   A verifying binary now stays wired (and a box a previous run of this bug unwired gets
   re-wired). `unwire` happens only where the binary itself fails its on-glass check, which
   is the branch that also removes it.

Also promote the "no +pfhdr marker" line from DEBUG to INFO. The handshake already reports
capture_supports_hdr=false at INFO while the one line saying WHY sat a level below it —
that asymmetry is what made this field report expensive to answer.

Verified: the meson reproduction above (exit 1 -> exit 0); all three added package names
resolve on trixie and satisfy their pkg-config modules; the four states of the changed
branch exercised in isolation (working binary stays wired, stock/missing binary unwired,
previously-unwired box re-wired). Not verified on real SteamOS glass — the lab VM was
unreachable from this machine.
2026-08-23 09:28:56 +02:00
enricobuehler cb07a8f983 fix(clients/android): a DualSense's buttons all reach the stream, and its Mute button works
Three defects reported against a Bluetooth DualSense on a Fire TV Stick 4K Max,
re-implemented from #371's diagnosis. #371 itself should not be merged: all
three problems are real, but each fix lands somewhere that breaks more hardware
than it repairs.

1. Some buttons never reach the stream. Fire OS is reported to tag certain
   DualSense buttons SOURCE_KEYBOARD even though the keycodes are standard
   BUTTON_*, and MainActivity's `event.isFromSource(SOURCE_GAMEPAD)` gate then
   drops them. The event's source class is the platform's per-event guess; the
   DEVICE's is the fact. New `MainActivity.fromPad` widens to the device — but
   ONLY for `KeyEvent.isGamepadButton` keycodes. That exclusion is the whole
   safety of it: DPAD keycodes are a keyboard's arrow keys and BACK is a
   remote's way out of the stream, and both share their keycodes with a pad.
   `Gamepad.isPad` is untouched (source-class only) and no vendor-id or
   device-name matching is added anywhere — the field report records both pads
   being IDENTIFIED correctly; only their button positions were wrong.

2. Touchpad click and Mute were dropped. Both have wire bits (BTN_TOUCHPAD,
   BTN_MISC1) and no Android keycode, so GENERIC_SONY's `0x13d`/`0x13e` rows now
   borrow BUTTON_15/BUTTON_16 to carry them into `buttonBit`. Inside
   GENERIC_SONY and nowhere else: `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR —
   L3 and R3 — in the standard Linux mapping, and they mean touchpad and mute
   only inside the straight-through report order a driverless pad uses. A row in
   SONY_MODERN, or an override above `padMap(dev)`, costs every Xbox pad, Switch
   Pro, 8BitDo, Steam Deck and hid-playstation DualSense both stick clicks.
   `correct()`'s `genericKeyCode` guard stays exactly as it was.

3. Mute toggles the mic — once per press, and only on a pad that has one.
   Edge-triggered through the existing `completesChord` as the one-button chord
   it is: `onButton` still calls `slotButton(down = true)` on auto-repeat, so an
   unguarded check would flap the mic for as long as the button is held. Gated
   on a new `Slot.hasMuteButton`, because BTN_MISC1 is the wire's misc/QAM bit
   and `Sc2Device` puts a Steam Controller 2's QAM button on it — "any MISC1"
   would mute the microphone on every QAM press. Resolved at slot open from what
   each path knows: the report order for an InputDevice, the declared kind for a
   capture link. Under the "local" system-button policy a real mute button is
   exempt from the early return (that policy means the press stays with this
   device, which is exactly what the toggle does) and loses only its wire send;
   every other system button behaves as before.

Tests: `every other pad keeps L3 and R3 on those scancodes` is the regression
that matters and fails on #371's shape (verified by reproducing it). Plus the
rewritten touchpad/mute assertions, the guard's negative path — untested in
either direction until now, because every existing case fed `correct()` the
keycode `Generic.kl` would have produced — and the mute button's edge rule in
GamepadChordTest. `an Xbox pad at the standard positions keeps X, Y and its
shoulders` is kept.

Not yet verified on hardware: no Fire TV Stick 4K Max or DualSense here, and no
adb device attached. §1's premise (the SOURCE_KEYBOARD tagging) is therefore
unconfirmed — the change is a no-op if it does not hold.
2026-08-23 01:07:59 +02:00
enricobuehler 11abff5343 fix(client): size the capacity probe from the session, and re-anchor if the burst eats the video
The startup link-capacity probe burst at a flat 2 Gbps on the reasoning that it
must measure the link and not itself. That reasoning is obsolete: the ABR
already clamps the measured ceiling to `stream_cap_kbps` (what this session's
mode + codec could plausibly use), so every bit measured above `cap / 0.7` is
discarded the moment it lands. The height bought a number nothing reads, and
paid bufferbloat for it — a constrained Wi-Fi link can black-hole under it
(measured on webOS: a 6 s probe timeout delaying first video to 14 s, and a
"successful" probe still reporting send_dropped=20211; the same shape is now
reported on a Fire TV Stick 4K Max).

Derive the target instead: `stream_cap_kbps × 2`, capped at the old 2 Gbps.
×2 is the smallest multiplier that can still prove the cap (the ceiling is
`delivered × 0.7`, so proving it needs `delivered ≥ cap × 1.43`), so this can
never cap anyone — a session whose mode justifies a high ceiling asks for a
high target by itself, and a mode `stream_ceiling_kbps` declines to size still
gets 2 Gbps. Deliberately not a platform `cfg!`: the constraint is the
session's, not Android's, and webOS has the same bug.

Second half of the black screen: if the burst takes the first keyframe down
with it, nothing re-requests one and the client sits on black until an
unrelated recovery path happens to fire. Ask for a keyframe at probe end when
no frame completed across the burst — compared against the count snapshotted
at the burst's leading edge rather than against 0, so it also covers a
mid-session embedder speed test that kills a running stream. One request per
probe, through the control task's coalescer, so it cannot IDR-storm.

`PUNKTFUNK_ABR_PROBE_KBPS` and its `> 0` filter are unchanged.
2026-08-23 00:45:42 +02:00
enricobuehler cf7baf3ba8 fix(client/android): acquireLatestImageAsync hands back a fence it already gave away
Every pf-decode SIGABRT on the Shield is fdsan catching a double-close of the
acquire fence the ASC presenter passes to ASurfaceTransaction_setBuffer, in
three shapes: inside Fence::Fence(int) under setBuffer when the number had
already been re-owned ("fd N is owned by unique_fd, was expected to be
unowned"), at the end of Transaction::apply when the layer state is torn down,
and in Parcel::freeDataNoInit once the number churns.

The fence is not ours to give. AImageReader::acquireLatestImage drains with a
single int* out-param it overwrites per image, then releases each dropped image
with whatever that out-param currently holds — the successor's fence — and
returns the last value written. So as soon as a burst gives it two images to
collapse, the caller receives an fd the reader has already adopted and closed,
plus one leaked fd per extra drop. This is unfixed as of AOSP main, so the
newest-wins collapse has to happen on our side.

Drain both present intents with acquireNextImageAsync, whose fence is always a
fresh dup we exclusively own, and let latency pick the newest itself — the loop
the smoothing FIFO already ran. Superseded candidates drop as before: image back
to the pool, its own acquire fence closed. Reader drops now show up in `skipped`
instead of vanishing inside the reader lock.
2026-08-22 23:41:05 +02:00
625 changed files with 62683 additions and 5521 deletions
+5 -1
View File
@@ -1,4 +1,8 @@
<!-- What and why — the diff says how. -->
<!-- TITLE: type(scope): summary — imperative, ≤72 chars, no trailing period.
Gitea turns this title into the merge subject, so it has to read as a commit.
Types: feat fix docs refactor perf test chore ci security. See docs/writing.md. -->
<!-- What and why — the diff says how. The investigation belongs here, not in the commit. -->
**User-facing fact changed?** (an install step, a knob, a port, what a feature does, a limit)
→ the docs-site page that owns it is updated in this PR, or this is n/a. Install/repo/port facts
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Promote
env:
+2 -2
View File
@@ -29,10 +29,10 @@ jobs:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Cache (gradle)
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
+27 -4
View File
@@ -110,7 +110,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# FIRST, because it costs a second and everything after it costs ten minutes.
#
@@ -171,12 +171,19 @@ jobs:
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
# Must precede every cargo step below: skia-bindings' ~19 MB prebuilt download runs inside
# a build script with no retry, and a truncated transfer here does not surface as a network
# error — it silently becomes a from-source Skia build that dies in the container. See the
# script for the measured failure.
- name: curl with retries (skia-bindings' prebuilt fetch has none)
run: sh scripts/ci/install-retrying-curl.sh
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
# anymore — sccache covers recompilation without shipping multi-GB tars per run.
- name: Cache (cargo registry)
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
@@ -185,7 +192,7 @@ jobs:
restore-keys: cargo-home-
- name: Cache (gradle)
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
@@ -209,9 +216,25 @@ jobs:
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
# both pointer widths are load-bearing and why the environment must not be duplicated here.
# The `STARTING A FULL BUILD` check turns the manual rule in this workflow's `env:` block
# ("Every ABI's log must show DOWNLOAD AND INSTALL SUCCEEDED") into something that fails the
# job by itself. Without it a missed prebuilt reads as a Gradle stack trace with the real
# cause ~1,800 lines up — which is exactly how 2026-08-22 spent a week looking like a lint
# failure. This is the first cargo step in the job, so it catches the drop earliest.
#
# No pipefail: the runner is dash. Capture, then decide.
- name: Clippy (Android target, deny warnings)
working-directory: clients/android
run: ./gradlew :kit:cargoNdkClippy --stacktrace
run: |
set -e
rc=0
./gradlew :kit:cargoNdkClippy --stacktrace > /tmp/android-clippy.log 2>&1 || rc=$?
cat /tmp/android-clippy.log
if grep -q "STARTING A FULL BUILD" /tmp/android-clippy.log; then
echo "::error::skia-bindings did not get its prebuilt archive and started building Skia from source — the download was dropped (see DOWNLOAD AND INSTALL FAILED above). This is a fetch failure, not a lint failure."
exit 1
fi
exit $rc
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
+5 -1
View File
@@ -29,7 +29,11 @@ jobs:
announce:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# SHA-pinned, unlike the plain `@v4` the build workflows use: this job holds
# UPDATE_MANIFEST_KEY — the Ed25519 key every host pins to decide whether an update is real —
# and a tag is mutable, so whoever can move it runs code in front of that key. Same style as
# the appleboy pins in deploy-services.yml; the trailing comment is the release it resolves to.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Publish the SIGNED stable update manifest — the moment every host's update check learns
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
+3 -3
View File
@@ -156,7 +156,7 @@ jobs:
gitea.event.pull_request.head.repo.fork != true
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Rust toolchain (self-healing on a fresh runner)
run: |
@@ -220,7 +220,7 @@ jobs:
TEAM_ID: F4H37KF6WC
PROJECT: clients/apple/Punktfunk.xcodeproj
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Select release Xcode
run: |
@@ -690,7 +690,7 @@ jobs:
runs-on: macos-arm64
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
run: |
+10 -8
View File
@@ -115,10 +115,12 @@ jobs:
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
}
# Arch ships bun in [extra], so the bootstrap takes the pacman-signed package instead of
# piping bun.sh's installer into root's shell — this job builds and publishes the package,
# and the installer would be upstream code choosing bytes we then ship. Kept behind the
# `command -v` guard rather than folded into the list above: the image's baked bun is not
# in pacman's db, so `--needed` cannot see it and would re-download bun on every run.
command -v bun >/dev/null || pacman -S --noconfirm --needed bun
bun --version
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
@@ -161,7 +163,7 @@ jobs:
fi
echo "healed: builder now links $HAVE"
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
@@ -172,7 +174,7 @@ jobs:
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
# is ever compiled here. Cached, that cost is paid once per runner.
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
@@ -239,7 +241,7 @@ jobs:
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
@@ -357,7 +359,7 @@ jobs:
# push restores the built package instead of spending ~10 minutes on someone else's C++ tree.
# Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary
# against newer system libs is the same risk the distro's own package carries between rebuilds.
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: gamescope
with:
path: dist-gamescope
+13 -13
View File
@@ -78,9 +78,9 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Cache /usr/local/cargo so the cargo-audit binary (and the advisory DB clone) persist.
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/bin
@@ -119,7 +119,7 @@ jobs:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# `bun audit` queries the registry advisory DB for the versions pinned in the tree's
# bun.lock. No install/build needed — it reads the manifest + lockfile. Fails the job on any
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
@@ -163,7 +163,7 @@ jobs:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: bun audit (non-blocking)
run: bun audit || echo "::warning::docs-site has known advisories (CMS/UI + nitropack chains) — tracked in punktfunk-planning design/cra-readiness.md"
@@ -176,7 +176,7 @@ jobs:
run:
working-directory: clients/decky
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# decky is pnpm-managed (pnpm-lock.yaml lockfileVersion 9.0 → pnpm 10 reads it). Like
# bun audit, `pnpm audit` needs no install/build — lockfile + registry advisory DB only.
# --prod: rollup bundles only the prod deps into the shipped plugin; devDependencies are
@@ -197,8 +197,8 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/bin
@@ -291,21 +291,21 @@ jobs:
# sites; do not blanket-disable the check.
MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Two caches, split on purpose so a Cargo.lock change does not re-download a ~400 MB
# toolchain. Both use their OWN `miri-` key prefix — never a shared one.
# The Miri sysroot is per-toolchain and per-target (two are built here: host + MSVC), so it
# belongs with the toolchain, not with the lockfile.
- name: cache the nightly toolchain + Miri sysroots
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/rustup/toolchains/${{ env.MIRI_TOOLCHAIN }}-x86_64-unknown-linux-gnu
~/.cache/miri
key: miri-toolchain-v1-${{ env.MIRI_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /usr/local/cargo/registry
key: miri-registry-v1-${{ hashFiles('Cargo.lock') }}
@@ -412,17 +412,17 @@ jobs:
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
RUSTC_WRAPPER: ""
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
# note there (and so an incomplete save from one job can never starve the other).
- name: cache the nightly toolchain
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /usr/local/cargo/registry
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: [self-hosted, gpu]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Tier-3 GPU stream benchmark
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
+27 -13
View File
@@ -64,7 +64,7 @@ jobs:
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -107,9 +107,15 @@ jobs:
# registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
# Skia from the same no-retry build-script fetch that took the android job out on
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
- name: curl with retries (skia-bindings' prebuilt fetch has none)
run: sh scripts/ci/install-retrying-curl.sh
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
@@ -122,7 +128,7 @@ jobs:
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# -v3-: the prior `cargo-target-<rustc>-*` cache was poisoned when the runner ran
@@ -263,16 +269,22 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
# Skia from the same no-retry build-script fetch that took the android job out on
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
- name: curl with retries (skia-bindings' prebuilt fetch has none)
run: sh scripts/ci/install-retrying-curl.sh
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
@@ -285,7 +297,7 @@ jobs:
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
@@ -338,7 +350,7 @@ jobs:
- name: Install git + node + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
# has the why: this box runs many jobs in parallel and drops packets under that load).
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
@@ -358,9 +370,11 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
# that only a browser rejects, which nothing else here would catch.
# Scoped to server/ and nitro-entry/: the console's browser code has no test runner, but two
# gates here do — the one keeping a plugin's origin apart from the console's, whose failure
# mode is a well-formed header that only a browser rejects, and the one picking which of the
# host's two identities the console serves, whose failure mode is a cert no browser accepts.
# Neither would be caught anywhere else.
- name: Test
run: bun run test
@@ -378,7 +392,7 @@ jobs:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Retried — see the web job above; this is the job the flake was measured on.
- name: Install dependencies
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
@@ -409,7 +423,7 @@ jobs:
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
@@ -430,7 +444,7 @@ jobs:
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
# still exist, data/platforms.json parses.
+30 -14
View File
@@ -100,7 +100,7 @@ jobs:
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -148,14 +148,14 @@ jobs:
# cache is NOT; see below.
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml).
@@ -204,7 +204,7 @@ jobs:
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
@@ -220,9 +220,25 @@ jobs:
run: |
# bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
# job stays green against the PREVIOUS image (docker.yml bootstrap lag).
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb (BUN_BIN, below),
# so an install script piped into root's shell is upstream code choosing bytes we then
# publish under REGISTRY_TOKEN. Not in Debian/Ubuntu, so a pin is the only option here.
# ONE bun across the repo: same version as rpm.yml and windows-host.yml, and the same
# asset + sum as rpm.yml (windows pins bun-windows-x64.zip, so its sum differs) — bump
# all three together (the sums are in the release's SHASUMS256.txt). `-baseline` on
# purpose: it needs no AVX2, so the bun we ship starts on every x86-64 box — something the
# auto-detecting installer never promised, since it reads the BUILDER's CPU, not the user's.
command -v bun >/dev/null || {
apt-get install -y --no-install-recommends unzip
curl -fsSL https://bun.sh/install | bash
BUN_VER=bun-v1.3.14
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
install -m0755 /tmp/bun /usr/local/bin/bun
}
export PATH="$HOME/.bun/bin:$PATH"
cd web
@@ -327,7 +343,7 @@ jobs:
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -357,14 +373,14 @@ jobs:
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
@@ -478,7 +494,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-gamescope-trixie:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Byte-identical to the other jobs' version step (pf-version.sh is deterministic per commit)
# — but only DISTRIBUTION is used here. The package version is the gamescope upstream
@@ -497,7 +513,7 @@ jobs:
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
# Keyed `-trixie-` so the noble cache entries (which only ever held misses) can't be hit.
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: gamescope
with:
path: gs-cache
@@ -571,7 +587,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -606,14 +622,14 @@ jobs:
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
@@ -693,7 +709,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Recomputes the SAME version string the builder jobs stamped — pf-version.sh is
# deterministic per commit and GITHUB_RUN_NUMBER is shared across a run's jobs — so the check
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
run:
working-directory: clients/decky
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: pnpm
run: |
+4 -4
View File
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync compose file
# SHA-pinned (receives DEPLOY_SSH_KEY): a moved tag would mean credential
@@ -75,7 +75,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync flatpak server compose + Caddyfile
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
@@ -110,7 +110,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync nix cache compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
@@ -150,7 +150,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync winget source compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
+4 -4
View File
@@ -126,7 +126,7 @@ jobs:
- image: punktfunk-flatpak-ci
dockerfile: ci/flatpak-ci.Dockerfile
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The key is the git TREE HASH of ci/ — every byte any of these Dockerfiles can see
# (they all use ci/ as build context). One key for the whole family on purpose: a
@@ -224,7 +224,7 @@ jobs:
env:
IMAGE: punktfunk-rust-ci-arm64cross
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Content key
run: |
@@ -305,7 +305,7 @@ jobs:
dockerfile: docs-site/Dockerfile
context: docs-site
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Login to registry
# Username must be the owner of the REGISTRY_TOKEN PAT, not the push actor.
@@ -340,7 +340,7 @@ jobs:
needs: apps
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Sync compose file
# SHA-pinned (not tag-pinned): this action receives DEPLOY_SSH_KEY + host/user/port, so a
+25 -6
View File
@@ -131,7 +131,7 @@ jobs:
# node comes from the image now (act_runner execs a JS action with the CONTAINER's
# node and injects none of its own), so checkout needs no install step ahead of it.
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Tooling (baked — assert, don't install)
run: |
@@ -178,7 +178,7 @@ jobs:
# overwriting the baked installation with an older copy of itself. The crate sources
# stay cached — they are keyed on Cargo.lock, which no image can pin.
- name: Cache flatpak-builder state (crate sources, ccache)
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .flatpak-builder
key: flatpak-builder-state-${{ hashFiles('Cargo.lock', 'packaging/flatpak/**') }}
@@ -215,8 +215,17 @@ jobs:
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
# untouched; cargo --offline only needs sources for the crates it compiles.
run: |
# PINNED to a commit and checked by SHA-256. `master` is a mutable ref, and this is
# third-party python executed in the SAME job that holds FLATPAK_GPG_PRIVATE_KEY — it
# chooses which crate sources the signed build vendors, so an upstream push (or a bad
# day at raw.githubusercontent) would be picking bytes we then sign. Bump both together:
# curl -fsSL .../<new-sha>/cargo/flatpak-cargo-generator.py | sha256sum
GEN_REF=f03a673abe6ce189cea1c2857e2b44af2dd79d1f
GEN_SHA=b373c8ab1a05378ec5d8ed0645c7b127bcec7d2f7a1798694fbc627d570d856c
curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
"https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$GEN_REF/cargo/flatpak-cargo-generator.py"
echo "$GEN_SHA /tmp/flatpak-cargo-generator.py" | sha256sum -c - \
|| { echo "::error::flatpak-cargo-generator.py sha256 mismatch at $GEN_REF"; exit 1; }
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
-o packaging/flatpak/cargo-sources.json
@@ -413,10 +422,14 @@ jobs:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"` — the same repo
# secret nix.yml publishes with (packaging/nix/README.md). Gated with the rest below: no
# pinned host key, no deploy, never a first-contact-trusts-anything push.
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
set -euo pipefail
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ]; then
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* not set — skipping repo deploy (bundle still published)."
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_KNOWN_HOSTS:-}" ]; then
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_*/DEPLOY_KNOWN_HOSTS not set — skipping repo deploy (bundle still published). See packaging/nix/README.md for the host key."
exit 0
fi
# 1) Import the signing key into a throwaway keyring; sign the repo.
@@ -481,7 +494,13 @@ jobs:
# objects so clients mid-update aren't broken; the fresh signed summary advertises latest.
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
# FLATPAK_GPG_PRIVATE_KEY and ships the signed OSTree repo, so `accept-new` — which trusts
# the first key it ever sees, and every run starts with an empty known_hosts, so EVERY run
# is a first contact — would hand the deploy key and the publish to anything that won the
# race for the address. The guard above skips the deploy when the secret is unset.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
# All idempotent — retried because the runner's link to unom-1 drops TCP dials under
# load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
+71 -1
View File
@@ -18,14 +18,25 @@ on:
paths:
- scripts/install.sh
- data/platforms.json
- scripts/ci/check-install-defaults.sh
- .gitea/workflows/installer-smoke.yml
pull_request:
paths:
- scripts/install.sh
- data/platforms.json
- scripts/ci/check-install-defaults.sh
- .gitea/workflows/installer-smoke.yml
jobs:
defaults:
name: derived defaults
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Assert per-family installer defaults
run: sh scripts/ci/check-install-defaults.sh
smoke:
name: smoke (${{ matrix.family }})
runs-on: ubuntu-24.04
@@ -39,18 +50,27 @@ jobs:
- family: debian-13
image: debian:trixie
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
rmweb: apt-get purge -y punktfunk-web
oncanary: grep -q ' canary main' /etc/apt/sources.list.d/punktfunk.list
onstable: grep -q ' stable main' /etc/apt/sources.list.d/punktfunk.list
- family: fedora-44
image: fedora:44
prep: dnf install -y -q curl git nodejs
rmweb: dnf remove -y punktfunk-web
oncanary: grep -q 'fedora-44-canary' /etc/yum.repos.d/punktfunk.repo
onstable: grep -q 'rpm/fedora-44$' /etc/yum.repos.d/punktfunk.repo
- family: arch
image: archlinux:base
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
rmweb: pacman -Rns --noconfirm punktfunk-web
oncanary: grep -q '^\[punktfunk-canary\]' /etc/pacman.conf && ! grep -q '^\[punktfunk\]' /etc/pacman.conf
onstable: grep -q '^\[punktfunk\]' /etc/pacman.conf && ! grep -q '^\[punktfunk-canary\]' /etc/pacman.conf
container:
image: ${{ matrix.image }}
steps:
- name: Prepare the container (${{ matrix.family }})
run: ${{ matrix.prep }}
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# No tty → the script runs as --yes; --no-start because there is no user systemd here.
# Root without sudo → the script's sudo shim, another path under test.
- name: Run the installer unattended
@@ -59,8 +79,58 @@ jobs:
run: |
punktfunk-host --version
punktfunk-host detect-conflicts
# The console is the whole management surface — pairing, approving a device, every setting.
# A host without it can be installed and still be useless, so assert it by name: the binary
# the package puts on PATH and the user unit step 6 enables.
- name: The web console is installed too
run: |
command -v punktfunk-web-server
test -f /usr/lib/systemd/user/punktfunk-web.service
- name: Re-running is a no-op install
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
# The reported Fedora failure, as a test: a box that has the host but lost (or never got)
# the console must get one back from a re-run. Before the per-package check, the installer
# saw punktfunk-host on PATH, declared itself done, and left the box without a console
# while still printing the console's URL.
- name: A host without a console gets one back on re-run
run: |
${{ matrix.rmweb }}
! command -v punktfunk-web-server
sh scripts/install.sh --yes --no-start
command -v punktfunk-web-server
# The channel switch, both ways, against the real registries — the half no textual gate can
# reach. Two things are actually at risk here and nowhere else: that the canary repo resolves
# at all, and that the walk back DOWN to stable happens instead of silently no-opping (canary
# is always a minor ahead, so every package manager refuses that step without being told).
# The version moving and then coming back is the proof; the repo-config greps localise a
# failure to the rewrite half. A red here can also mean the family's canary channel is empty
# — check https://git.unom.io/unom/-/packages before hunting in the script.
- name: Switch to canary and back to stable
run: |
set -eu
stable_v=$(punktfunk-host --version | head -1)
sh scripts/install.sh --yes --no-start --channel canary
${{ matrix.oncanary }}
canary_v=$(punktfunk-host --version | head -1)
test "$canary_v" != "$stable_v" \
|| { echo "switch to canary left the version at $stable_v"; exit 1; }
command -v punktfunk-web-server
sh scripts/install.sh --yes --no-start --channel stable
${{ matrix.onstable }}
back_v=$(punktfunk-host --version | head -1)
test "$back_v" = "$stable_v" \
|| { echo "walk back to stable gave $back_v, not $stable_v (a downgrade that did not happen?)"; exit 1; }
# A re-run with no --channel must follow the box, not the flag's stable default.
- name: A bare re-run does not drag a canary box back to stable
run: |
set -eu
sh scripts/install.sh --yes --no-start --channel canary
canary_v=$(punktfunk-host --version | head -1)
sh scripts/install.sh --yes --no-start
${{ matrix.oncanary }}
test "$(punktfunk-host --version | head -1)" = "$canary_v" \
|| { echo "a bare re-run moved a canary box off $canary_v"; exit 1; }
sh scripts/install.sh --yes --no-start --channel stable
- name: --uninstall takes the packages and the repo off again
run: |
sh scripts/install.sh --yes --uninstall
@@ -48,7 +48,7 @@ jobs:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -72,14 +72,14 @@ jobs:
# it is profile-independent.
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
# This job builds RELEASE (see the build step) in the same image and target layout as
+100
View File
@@ -0,0 +1,100 @@
# Does `punktfunk-host` still COMPILE on a third platform? Nothing more.
#
# There is no macOS host backend and none is shipped. What this protects is the seam: every
# platform-gated call site saying which platform it means, rather than assuming Linux-or-Windows
# and only being caught when someone tries. Left ungated the seam rots quietly — it drifted from
# 9 unguarded call sites to 11 in the four weeks after it was first measured, with nobody working
# on macOS at all (issue #299, Tier A). A compile check is the cheapest thing that notices.
#
# ⚠ WHY NOT A JOB IN apple.yml, which already owns this runner. Two reasons, both load-bearing:
# * That workflow's `paths:` filter is deliberately narrow — punktfunk-core and the Apple client
# — because the mac mini is shared and an xcframework build is heavy. A host-crate edit would
# never have fired it, so the gate would have been decorative.
# * `distribute` there is `needs: swift`. A sibling job is fine, but anything that grows a
# dependency on the Swift gate can block a TestFlight upload over a host-side compile hole
# that has nothing to do with the client.
#
# Runs on the same self-hosted `macos-arm64` runner (home-mac-mini-1; scripts/ci/setup-macos-runner.sh)
# and shares apple.yml's sccache bucket, so the dependency graph is usually warm.
name: macos-host
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- '.gitea/workflows/macos-host.yml'
pull_request:
paths:
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- '.gitea/workflows/macos-host.yml'
workflow_dispatch:
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
CARGO_INCREMENTAL: "0"
jobs:
# SECURITY: same caveat as apple.yml's `swift` job — this compiles PULL-REQUEST code on the
# host-mode, persistent runner that apple.yml's signing job also uses. The `if:` is the same
# fail-open backstop: it skips fork PRs where Gitea reports the fork flag, and still runs
# same-repo PRs (and any where the flag is absent), so it never blocks internal PR CI.
check:
runs-on: macos-arm64
if: >-
gitea.event_name != 'pull_request' ||
gitea.event.pull_request.head.repo.fork != true
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Rust toolchain (self-healing on a fresh runner)
run: |
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal
fi
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH"
- name: sccache (self-healing install)
run: sh scripts/ci/ensure-sccache.sh
# The mic pump decodes with `opus`, which vendors libopus through CMake — the same
# requirement, and the same self-healing install, as apple.yml's xcframework build.
- name: CMake (for the vendored libopus builds)
run: |
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH.
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
command -v cmake >/dev/null || "$BREW" install cmake
echo "$BREW_BIN" >> "$GITHUB_PATH"
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
# `cmake_minimum_required`; treat 3.5 as the policy minimum.
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
# `check`, not `clippy`, and no `-D warnings`: the graph carries code that is dead on macOS
# (`pf-encode::vbv_frames_env`, the `hidout` sink in `native/input.rs`). Failing the gate on
# that would only teach people to ignore it. This asks one question — does it compile.
- name: Check (punktfunk-host, default features)
run: cargo check -p punktfunk-host --locked
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
+14 -4
View File
@@ -145,7 +145,7 @@ jobs:
# and a real `nix build` of a trivial derivation succeeds).
NIX_REMOTE: ""
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout;
# rsync + ssh to ship the built cache to unom-1. (node:22-bookworm is the full image and
@@ -235,16 +235,19 @@ jobs:
env:
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"`. Gated here with
# the rest: no pinned host key, no publish — never a first-contact-trusts-anything deploy.
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
# Guard BEFORE the build, not before the upload: an unconfigured cache must not cost an
# hour of rustc first. No-ops cleanly until the secret exists, exactly as flatpak.yml's
# repo deploy does, so this workflow stays green through setup.
run: |
set -eu
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ]; then
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ] && [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
echo "go=true" >> "$GITHUB_OUTPUT"
else
echo "go=false" >> "$GITHUB_OUTPUT"
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST not set — skipping the binary cache publish (see packaging/nix/README.md)."
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST/DEPLOY_KNOWN_HOSTS not set — skipping the binary cache publish (see packaging/nix/README.md)."
fi
- name: Build the publishable packages
@@ -269,6 +272,7 @@ jobs:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
# `set -eu`, NOT `set -euo pipefail`: act_runner may execute a step's `run:` under dash in
# these containers (see scripts/ci/ensure-sccache.sh), and dash dies on `-o pipefail` with
@@ -313,7 +317,13 @@ jobs:
# unom-1 drops TCP dials under load.
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
# NIX_CACHE_SIGNING_KEY and ships the signed cache, so `accept-new` — which trusts the
# first key it ever sees, and every run starts with an empty known_hosts, so EVERY run is
# a first contact — would hand the deploy key and the publish to anything that won the
# race for the address. Preflight above skips the publish when the secret is unset.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/nar"
# ⚠ ORDER IS LOAD-BEARING: NARs first, narinfos second. A narinfo whose NAR has not landed
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
+20 -6
View File
@@ -86,7 +86,7 @@ jobs:
env:
CARGO_HOME: /usr/local/cargo
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
@@ -119,13 +119,27 @@ jobs:
dnf -y install gamescope || true
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
# This job holds RPM_GPG_PRIVATE_KEY, and the spec VENDORS this very binary into
# punktfunk-web, so an install script piped into root's shell is upstream code running in
# front of the signing key AND choosing bytes we then sign. Same discipline as
# windows-host.yml's bun pin. Bump BUN_VER and BUN_SHA together (the sums are published in
# the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we
# ship starts on every x86-64 box — something the auto-detecting installer never promised,
# since it reads the BUILDER's CPU, not the user's.
command -v bun >/dev/null || {
dnf -y install unzip
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
BUN_VER=bun-v1.3.14
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
install -m0755 /tmp/bun /usr/local/bin/bun
}
bun --version
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /usr/local/cargo/registry
key: cargo-home-fedora-${{ hashFiles('Cargo.lock') }}
@@ -167,7 +181,7 @@ jobs:
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
@@ -270,7 +284,7 @@ jobs:
# script). So the key is that directory's hash and a normal push restores a binary instead of
# building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly
# like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60).
- uses: actions/cache@v4
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: gamescope
with:
path: gs-cache
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
steps:
# fetch-depth 0: the dispatch path derives the canary base from the tag history
# (scripts/ci/pf-version.sh), which a shallow clone cannot see.
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
- name: Install git + node + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
# explicitly since build-storybook has no prebuild hook of its own.
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
+1 -1
View File
@@ -188,7 +188,7 @@ jobs:
# rust-skia adds the target.
session_flags: '--no-default-features'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
run:
shell: pwsh
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Probe driver toolchain (informational — never fails the job)
continue-on-error: true
@@ -142,7 +142,7 @@ jobs:
# (the shipping pack proves it). A 0.71-era layout-test overflow once needed LLVM 21; the 0.72 bump
# retired that — see design/windows-build-and-packaging.md.
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
# Shared self-provision step (also used by windows-client.yml/windows-host.yml) so
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
+4 -4
View File
@@ -112,7 +112,7 @@ jobs:
runs-on: windows-amd64
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
shell: pwsh
@@ -284,7 +284,7 @@ jobs:
# see unom/infra runners/ci-core/README.md).
- name: Cache web console output
id: webconsole
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.output
key: web-console-win-${{ hashFiles('web/**', 'sdk/**') }}
@@ -541,7 +541,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Publish the canary update manifest
env:
@@ -566,7 +566,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
# so the result cannot drift and re-running any tag reproduces it byte for byte.
+14
View File
@@ -2,6 +2,20 @@
Guidance for coding agents working in this repository.
## Writing standards
Read `docs/writing.md` before you write a commit message, a `CHANGELOG.md` entry, or a comment.
It is the house style for all three, and §4 is a per-PR checklist. The short version:
- Commit subject is `type(scope): summary` — imperative, **72-character hard cap**, no trailing
period, one logical change. No `Co-Authored-By` trailer.
- The commit body is *why*, wrapped at 72. The investigation, the measurements and the rejected
paths go on the pull request, never in the message.
- Write the Gitea PR title as a conventional commit; Gitea makes it the merge subject.
- New `CHANGELOG.md` sections use Keep a Changelog categories. Leave the older sections alone.
- A comment states an invariant or a trap. A comment never enforces a trust boundary — a type,
a test or an assertion does.
## Agent skills
### Issue tracker
+1709
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -118,10 +118,24 @@ must still exist in the tree, the counts of undocumented `PUNKTFUNK_*` variables
`punktfunk-host` subcommands may never grow (document the new knob, or consciously raise the
baseline in the script), and internal docs links must resolve.
Match the surrounding code's comment density and naming. Commit messages end with the
`Co-Authored-By` trailer (see `git log`).
Match the surrounding code's comment density and naming.
See the [README's Build & test section](README.md#build--test-from-source) for the extra dev
commands (the FEC loss harness, the standalone C-ABI proof) and
[Design invariants](README.md#design-invariants) for the rules a change is expected to hold to, and
the [docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
## How to write it (commits, changelog, comments)
House style lives in **[docs/writing.md](docs/writing.md)**, which also carries a per-PR checklist.
The three rules you need before your first commit:
- **Commit subjects follow [Conventional Commits](https://www.conventionalcommits.org/) —
`type(scope): summary`, 72-character cap, imperative, no trailing period.** The *why* goes in
the body, wrapped at 72. The investigation goes on the pull request, not in the message. A Gitea
PR title becomes the merge subject, so write the PR title as a conventional commit too.
- **New `CHANGELOG.md` sections use [Keep a Changelog](https://keepachangelog.com/) categories**
— `Breaking` / `Added` / `Changed` / `Fixed` / `Security` — plus the version table. Keep the
existing sections as they are. What a *user* can do goes in `docs/releases/vX.Y.Z.md` instead.
- **A comment states an invariant or a trap, not a recap of the diff.** If a trust boundary
matters, a type, test or assertion has to enforce it — a comment alone never does.
Generated
+44 -43
View File
@@ -747,9 +747,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cipher",
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1959,9 +1959,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.31.2"
version = "0.34.0"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,13 +2996,14 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ash",
"async-channel",
"libc",
"libloading 0.9.0",
"log",
"mdns-sd",
"openh264",
"opus",
@@ -3022,6 +3023,8 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"tracing",
"tracing-log",
"tracing-subscriber",
"ureq",
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
@@ -3032,7 +3035,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3050,7 +3053,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ash",
@@ -3073,7 +3076,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3083,7 +3086,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ash",
@@ -3109,7 +3112,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"libc",
@@ -3122,7 +3125,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3136,11 +3139,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.31.2"
version = "0.34.0"
[[package]]
name = "pf-inject"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3169,14 +3172,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ash",
@@ -3191,7 +3194,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"serde",
"serde_json",
@@ -3199,7 +3202,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3211,7 +3214,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3220,7 +3223,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3253,7 +3256,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3264,7 +3267,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3275,7 +3278,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ash",
@@ -3487,7 +3490,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3497,7 +3500,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"android_logger",
"anyhow",
@@ -3521,7 +3524,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3538,23 +3541,21 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"log",
"pf-client-core",
"pf-console-ui",
"pf-presenter",
"punktfunk-core",
"serde_json",
"tracing",
"tracing-log",
"tracing-subscriber",
"winresource",
]
[[package]]
name = "punktfunk-client-windows"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3572,7 +3573,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3605,7 +3606,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"pf-encode",
"tracing",
@@ -3614,7 +3615,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3684,7 +3685,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3698,7 +3699,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"anyhow",
"ksni",
@@ -3722,7 +3723,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.31.2"
version = "0.34.0"
dependencies = [
"bindgen",
"cmake",
@@ -4821,7 +4822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.31.2"
version = "0.34.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+2 -2
View File
@@ -63,7 +63,7 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (Windows 11 22H2+, x64) | ✅ Beta — shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **Windows host** (Windows 11 22H2+, x64) | ✅ Beta — shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. All three vendors validated on real hardware; NVENC has the most field time |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
@@ -102,7 +102,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|--------|---------|-------|
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Fedora** (dnf) | `sudo dnf install punktfunk punktfunk-web punktfunk-scripting` *(after adding the repo)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
+4 -1
View File
@@ -82,7 +82,10 @@ us beyond the download itself.
checks every package for you. `rpmkeys --checksig` on a downloaded RPM verifies it by hand.
- **The Bazzite sysext feed** carries a detached signature over its `SHA256SUMS`, from that same
key. `punktfunk-sysext` verifies it before installing and refuses a feed it cannot verify — the
public key is baked into the script rather than fetched from the feed.
public key is baked into the script rather than fetched from the feed. The manifest also names
the feed it was signed for and carries a monotonic publish serial, both inside the signed bytes,
so a genuinely-signed manifest replayed from another channel — or an older one put back — is
refused too.
- **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot
reach its code-signing certificate fails to build rather than falling back to a self-signed one.
Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`.
+31 -175
View File
@@ -7,11 +7,12 @@ below. Each is distributed under its own permissive license; the full license te
follow the manifest. This file is generated by scripts/gen-third-party-notices.py
(or `cargo about`, see about.toml) — do not edit by hand.
Total third-party crates: 575
Total third-party crates: 566
----------------------------------------------------------------------------
VENDORED THIRD-PARTY SOURCE (inside first-party crates)
----------------------------------------------------------------------------
Lucide 0.462.0 (icon path data, crates/pf-console-ui) — https://lucide.dev
pyrowave (vendored, crates/pyrowave-sys) — https://github.com/Themaister/pyrowave
Granite subset (vendored, crates/pyrowave-sys) — https://github.com/Themaister/Granite
volk (vendored, crates/pyrowave-sys) — https://github.com/zeux/volk
@@ -97,7 +98,7 @@ MANIFEST (crate version — SPDX license — source)
cfg-expr 0.20.8 — MIT OR Apache-2.0 — https://github.com/EmbarkStudios/cfg-expr
cfg-if 1.0.4 — MIT OR Apache-2.0 — https://github.com/rust-lang/cfg-if
cfg_aliases 0.2.2 — MIT — https://github.com/katharostech/cfg_aliases
chacha20 0.10.1 — MIT OR Apache-2.0 — https://github.com/RustCrypto/stream-ciphers
chacha20 0.10.2 — MIT OR Apache-2.0 — https://github.com/RustCrypto/stream-ciphers
chacha20poly1305 0.11.0 — Apache-2.0 OR MIT — https://github.com/RustCrypto/AEADs
ciborium 0.2.2 — Apache-2.0 — https://github.com/enarx/ciborium
ciborium-io 0.2.2 — Apache-2.0 — https://github.com/enarx/ciborium
@@ -159,8 +160,6 @@ MANIFEST (crate version — SPDX license — source)
errno 0.3.14 — MIT OR Apache-2.0 — https://github.com/lambda-fairy/rust-errno
event-listener 5.4.2 — Apache-2.0 OR MIT — https://github.com/smol-rs/event-listener
event-listener-strategy 0.5.4 — Apache-2.0 OR MIT — https://github.com/smol-rs/event-listener-strategy
fallible-iterator 0.3.0 — MIT/Apache-2.0 — https://github.com/sfackler/rust-fallible-iterator
fallible-streaming-iterator 0.1.9 — MIT/Apache-2.0 — https://github.com/sfackler/fallible-streaming-iterator
fastbloom 0.17.0 — MIT OR Apache-2.0 — https://github.com/tomtomwombat/fastbloom/
fastrand 2.5.0 — Apache-2.0 OR MIT — https://github.com/smol-rs/fastrand
fdeflate 0.3.7 — MIT OR Apache-2.0 — https://github.com/image-rs/fdeflate
@@ -215,11 +214,9 @@ MANIFEST (crate version — SPDX license — source)
gtk4 0.11.4 — MIT — https://github.com/gtk-rs/gtk4-rs
gtk4-macros 0.11.4 — MIT — https://github.com/gtk-rs/gtk4-rs
gtk4-sys 0.11.4 — MIT — https://github.com/gtk-rs/gtk4-rs
h2 0.4.15 — MIT — https://github.com/hyperium/h2
h2 0.4.19 — MIT — https://github.com/hyperium/h2
half 2.7.1 — MIT OR Apache-2.0 — https://github.com/VoidStarKat/half-rs
hashbrown 0.16.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/hashbrown
hashbrown 0.17.1 — MIT OR Apache-2.0 — https://github.com/rust-lang/hashbrown
hashlink 0.12.1 — MIT OR Apache-2.0 — https://github.com/djc/hashlink
heck 0.5.0 — MIT OR Apache-2.0 — https://github.com/withoutboats/heck
hermit-abi 0.5.2 — MIT OR Apache-2.0 — https://github.com/hermit-os/hermit-rs
hex 0.4.3 — MIT OR Apache-2.0 — https://github.com/KokaKiwi/rust-hex
@@ -262,7 +259,6 @@ MANIFEST (crate version — SPDX license — source)
libm 0.2.16 — MIT — https://github.com/rust-lang/compiler-builtins
libspa 0.9.2 — MIT — https://gitlab.freedesktop.org/pipewire/pipewire-rs
libspa-sys 0.9.2 — MIT — https://gitlab.freedesktop.org/pipewire/pipewire-rs
libsqlite3-sys 0.38.2 — MIT — https://github.com/rusqlite/rusqlite
linux-raw-sys 0.12.1 — Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT — https://github.com/sunfishcode/linux-raw-sys
lock_api 0.4.14 — MIT OR Apache-2.0 — https://github.com/Amanieu/parking_lot
log 0.4.33 — MIT OR Apache-2.0 — https://github.com/rust-lang/log
@@ -373,11 +369,8 @@ MANIFEST (crate version — SPDX license — source)
relm4-css 0.11.0 — Apache-2.0 OR MIT — https://github.com/Relm4/Relm4
relm4-macros 0.11.0 — Apache-2.0 OR MIT — https://github.com/Relm4/Relm4
ring 0.17.14 — Apache-2.0 AND ISC — https://github.com/briansmith/ring
roxmltree 0.21.1 — MIT OR Apache-2.0 — https://github.com/RazrFalcon/roxmltree
rpkg-config 0.1.2 — Zlib OR MIT OR Apache-2.0 — https://github.com/maia-s/rpkg-config-rs
rsa 0.9.10 — MIT OR Apache-2.0 — https://github.com/RustCrypto/RSA
rsqlite-vfs 0.1.1 — MIT
rusqlite 0.40.2 — MIT — https://github.com/rusqlite/rusqlite
rustc-hash 2.1.3 — Apache-2.0 OR MIT — https://github.com/rust-lang/rustc-hash
rustc_version 0.4.1 — MIT OR Apache-2.0 — https://github.com/djc/rustc-version-rs
rusticata-macros 4.1.0 — MIT/Apache-2.0 — https://github.com/rusticata/rusticata-macros.git
@@ -436,7 +429,6 @@ MANIFEST (crate version — SPDX license — source)
spake2 0.4.0 — MIT OR Apache-2.0 — https://github.com/RustCrypto/PAKEs/tree/master/spake2
spin 0.9.9 — MIT — https://github.com/mvdnes/spin-rs.git
spki 0.7.3 — Apache-2.0 OR MIT — https://github.com/RustCrypto/formats/tree/master/spki
sqlite-wasm-rs 0.5.5 — MIT — https://github.com/Spxg/sqlite-wasm-rs
strsim 0.11.1 — MIT — https://github.com/rapidfuzz/strsim-rs
strum 0.26.3 — MIT — https://github.com/Peternator7/strum
strum_macros 0.26.4 — MIT — https://github.com/Peternator7/strum
@@ -620,7 +612,6 @@ Crates whose package did not embed a license file (SPDX + source only)
openh264-sys2 0.9.8 — BSD-2-Clause — https://github.com/ralfbiedert/openh264-rs
r-efi 5.3.0 — MIT OR Apache-2.0 OR LGPL-2.1-or-later — https://github.com/r-efi/r-efi
r-efi 6.0.0 — MIT OR Apache-2.0 OR LGPL-2.1-or-later — https://github.com/r-efi/r-efi
rsqlite-vfs 0.1.1 — MIT
rustls-platform-verifier-android 0.1.1 — MIT OR Apache-2.0 — https://github.com/rustls/rustls-platform-verifier
sdl3-image-src 3.4.4 — Zlib — https://codeberg.org/maia/sdl3-sys-rs
sdl3-mixer-src 3.2.4 — Zlib — https://codeberg.org/maia/sdl3-sys-rs
@@ -890,7 +881,7 @@ DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-APACHE) applies to: aead 0.6.1, aes 0.9.2, aes-gcm 0.11.0, base64ct 1.8.3, block-buffer 0.10.4, block-buffer 0.12.1, block-padding 0.4.2, cbc 0.2.1, chacha20 0.10.1, chacha20poly1305 0.11.0, cipher 0.5.2, const-oid 0.10.2, const-oid 0.9.6, cpubits 0.1.1, cpufeatures 0.2.17, cpufeatures 0.3.0, crypto-common 0.1.7, crypto-common 0.2.2, ctr 0.10.1, der 0.7.10, digest 0.10.7, digest 0.11.3, ghash 0.6.0, hkdf 0.12.4, hmac 0.12.1, hmac 0.13.0, hybrid-array 0.4.14, inout 0.2.2, pem-rfc7468 0.7.0, pkcs1 0.7.5, pkcs8 0.10.2, poly1305 0.9.1, polyval 0.7.3, sha2 0.10.9, sha2 0.11.0, signature 2.2.0, spake2 0.4.0, spki 0.7.3, universal-hash 0.6.1
The following license (LICENSE-APACHE) applies to: aead 0.6.1, aes 0.9.2, aes-gcm 0.11.0, base64ct 1.8.3, block-buffer 0.10.4, block-buffer 0.12.1, block-padding 0.4.2, cbc 0.2.1, chacha20 0.10.2, chacha20poly1305 0.11.0, cipher 0.5.2, const-oid 0.10.2, const-oid 0.9.6, cpubits 0.1.1, cpufeatures 0.2.17, cpufeatures 0.3.0, crypto-common 0.1.7, crypto-common 0.2.2, ctr 0.10.1, der 0.7.10, digest 0.10.7, digest 0.11.3, ghash 0.6.0, hkdf 0.12.4, hmac 0.12.1, hmac 0.13.0, hybrid-array 0.4.14, inout 0.2.2, pem-rfc7468 0.7.0, pkcs1 0.7.5, pkcs8 0.10.2, poly1305 0.9.1, polyval 0.7.3, sha2 0.10.9, sha2 0.11.0, signature 2.2.0, spake2 0.4.0, spki 0.7.3, universal-hash 0.6.1
----------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
@@ -1158,7 +1149,7 @@ DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-MIT) applies to: aes-gcm 0.11.0, chacha20 0.10.1, chacha20poly1305 0.11.0, ghash 0.6.0, polyval 0.7.3
The following license (LICENSE-MIT) applies to: aes-gcm 0.11.0, chacha20 0.10.2, chacha20poly1305 0.11.0, ghash 0.6.0, polyval 0.7.3
----------------------------------------------------------------------------
Copyright (c) 2019-2026 The RustCrypto Project Developers
@@ -1737,7 +1728,7 @@ SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-APACHE) applies to: annotate-snippets 0.11.5, anstream 1.0.0, anstyle 1.0.14, anstyle-parse 1.0.0, anstyle-query 1.1.5, anstyle-wincon 3.0.11, clap 4.6.6, clap_builder 4.6.6, clap_lex 1.1.0, colorchoice 1.0.5, crc32fast 1.5.0, env_filter 0.1.4, env_filter 2.0.0, env_logger 0.11.11, fallible-iterator 0.3.0, fallible-streaming-iterator 0.1.9, hex 0.4.3, is_terminal_polyfill 1.70.2, jni-sys 0.3.1, jni-sys 0.4.1, once_cell_polyfill 1.70.2, quick-error 1.2.3, serde_spanned 1.1.1, toml 0.9.12+spec-1.1.0, toml 1.1.4+spec-1.1.0, toml_datetime 0.7.5+spec-1.1.0, toml_datetime 1.1.1+spec-1.1.0, toml_edit 0.25.13+spec-1.1.0, toml_parser 1.1.3+spec-1.1.0, toml_writer 1.1.2+spec-1.1.0, winapi 0.3.9
The following license (LICENSE-APACHE) applies to: annotate-snippets 0.11.5, anstream 1.0.0, anstyle 1.0.14, anstyle-parse 1.0.0, anstyle-query 1.1.5, anstyle-wincon 3.0.11, clap 4.6.6, clap_builder 4.6.6, clap_lex 1.1.0, colorchoice 1.0.5, crc32fast 1.5.0, env_filter 0.1.4, env_filter 2.0.0, env_logger 0.11.11, hex 0.4.3, is_terminal_polyfill 1.70.2, jni-sys 0.3.1, jni-sys 0.4.1, once_cell_polyfill 1.70.2, quick-error 1.2.3, serde_spanned 1.1.1, toml 0.9.12+spec-1.1.0, toml 1.1.4+spec-1.1.0, toml_datetime 0.7.5+spec-1.1.0, toml_datetime 1.1.1+spec-1.1.0, toml_edit 0.25.13+spec-1.1.0, toml_parser 1.1.3+spec-1.1.0, toml_writer 1.1.2+spec-1.1.0, winapi 0.3.9
----------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
@@ -2276,7 +2267,7 @@ SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-APACHE) applies to: asn1-rs 0.7.2, asn1-rs-derive 0.6.0, assert_matches 1.5.0, async-channel 2.5.0, async-executor 1.14.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.1.1, async-signal 0.2.14, async-task 4.7.1, atomic-waker 1.1.2, autocfg 1.5.1, base64 0.22.1, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.1, blocking 1.6.2, bumpalo 3.20.3, cast 0.3.0, cc 1.4.2, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.8.2, criterion-plot 0.8.2, crossbeam-deque 0.8.7, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.22, curve25519-dalek-derive 0.1.1, der-parser 10.0.0, displaydoc 0.2.7, either 1.17.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, filetime 0.2.29, find-msvc-tools 0.1.10, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, fs-err 3.3.1, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glob 0.3.4, hashbrown 0.16.1, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, httparse 1.10.1, indexmap 2.14.0, itertools 0.13.0, jobserver 0.1.35, js-sys 0.3.104, khronos-egl 6.0.0, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, mime 0.3.17, nasm-rs 0.3.2, num-bigint 0.4.8, num-bigint-dig 0.8.6, num-derive 0.4.2, num-integer 0.1.47, num-iter 0.1.46, num-traits 0.2.19, num_cpus 1.17.0, oid-registry 0.8.1, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, ordered-stream 0.2.0, page_size 0.6.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, pkg-config 0.3.33, png 0.18.1, polling 3.11.0, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.13.1, regex-automata 0.4.18, regex-syntax 0.8.11, roxmltree 0.21.1, rsa 0.9.10, rustc_version 0.4.1, rusticata-macros 4.1.0, rustix 1.1.4, rustls 0.23.43, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.15.2, socket2 0.6.5, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.10, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, uuid 1.24.0, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.127, wasm-bindgen-macro 0.2.127, wasm-bindgen-macro-support 0.2.127, wasm-bindgen-shared 0.2.127, weezl 0.1.12, wit-bindgen 0.57.1, x509-parser 0.18.1, xattr 1.6.1, yasna 0.6.0
The following license (LICENSE-APACHE) applies to: asn1-rs 0.7.2, asn1-rs-derive 0.6.0, assert_matches 1.5.0, async-channel 2.5.0, async-executor 1.14.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.1.1, async-signal 0.2.14, async-task 4.7.1, atomic-waker 1.1.2, autocfg 1.5.1, base64 0.22.1, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.1, blocking 1.6.2, bumpalo 3.20.3, cast 0.3.0, cc 1.4.2, cexpr 0.6.0, cfg-if 1.0.4, cmake 0.1.58, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation-sys 0.8.7, criterion 0.8.2, criterion-plot 0.8.2, crossbeam-deque 0.8.7, crossbeam-epoch 0.9.20, crossbeam-utils 0.8.22, curve25519-dalek-derive 0.1.1, der-parser 10.0.0, displaydoc 0.2.7, either 1.17.0, equivalent 1.0.2, errno 0.3.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, filetime 0.2.29, find-msvc-tools 0.1.10, fixedbitset 0.5.7, flate2 1.1.9, fnv 1.0.7, form_urlencoded 1.2.2, fs-err 3.3.1, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glob 0.3.4, hashbrown 0.17.1, heck 0.5.0, hermit-abi 0.5.2, httparse 1.10.1, indexmap 2.14.0, itertools 0.13.0, jobserver 0.1.35, js-sys 0.3.104, khronos-egl 6.0.0, lazy_static 1.5.0, linux-raw-sys 0.12.1, lock_api 0.4.14, log 0.4.33, mime 0.3.17, nasm-rs 0.3.2, num-bigint 0.4.8, num-bigint-dig 0.8.6, num-derive 0.4.2, num-integer 0.1.47, num-iter 0.1.46, num-traits 0.2.19, num_cpus 1.17.0, oid-registry 0.8.1, once_cell 1.21.4, openssl-probe 0.2.1, opus 0.3.1, ordered-stream 0.2.0, page_size 0.6.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, pkg-config 0.3.33, png 0.18.1, polling 3.11.0, proptest 1.11.0, rayon 1.12.0, rayon-core 1.13.0, regex 1.13.1, regex-automata 0.4.18, regex-syntax 0.8.11, rsa 0.9.10, rustc_version 0.4.1, rusticata-macros 4.1.0, rustix 1.1.4, rustls 0.23.43, rustls-native-certs 0.8.4, rusty-fork 0.3.1, scopeguard 1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.15.2, socket2 0.6.5, system-deps 7.0.8, tar 0.4.46, tempfile 3.27.0, thread_local 1.1.10, tinytemplate 1.2.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, uuid 1.24.0, vcpkg 0.2.15, version_check 0.9.5, wait-timeout 0.2.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.127, wasm-bindgen-macro 0.2.127, wasm-bindgen-macro-support 0.2.127, wasm-bindgen-shared 0.2.127, weezl 0.1.12, wit-bindgen 0.57.1, x509-parser 0.18.1, xattr 1.6.1, yasna 0.6.0
----------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
@@ -2813,7 +2804,7 @@ DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-APACHE) applies to: atomig 0.4.3, bit-set 0.8.0, bit-vec 0.8.0, bit-vec 0.9.1, cfg-expr 0.20.8, defmt 1.1.1, defmt-macros 1.1.1, downcast-rs 1.2.1, hashlink 0.12.1, minimal-lexical 0.2.1
The following license (LICENSE-APACHE) applies to: atomig 0.4.3, bit-set 0.8.0, bit-vec 0.8.0, bit-vec 0.9.1, cfg-expr 0.20.8, defmt 1.1.1, defmt-macros 1.1.1, downcast-rs 1.2.1, minimal-lexical 0.2.1
----------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
@@ -6337,54 +6328,6 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-MIT) applies to: fallible-iterator 0.3.0
----------------------------------------------------------------------------
Copyright (c) 2015 The rust-openssl-verify Developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-MIT) applies to: fallible-streaming-iterator 0.1.9
----------------------------------------------------------------------------
Copyright (c) 2016 The fallible-streaming-iterator Developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-MIT) applies to: fastbloom 0.17.0
----------------------------------------------------------------------------
@@ -7583,7 +7526,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE) applies to: h2 0.4.15
The following license (LICENSE) applies to: h2 0.4.19
----------------------------------------------------------------------------
Copyright (c) 2017 h2 authors
@@ -7637,7 +7580,7 @@ SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-MIT) applies to: hashbrown 0.16.1, hashbrown 0.17.1
The following license (LICENSE-MIT) applies to: hashbrown 0.17.1
----------------------------------------------------------------------------
Copyright (c) 2016 Amanieu d'Antras
@@ -7666,37 +7609,6 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-MIT) applies to: hashlink 0.12.1
----------------------------------------------------------------------------
This work is derived in part from the `linked-hash-map` crate, Copyright (c)
2015 The Rust Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-MIT) applies to: heck 0.5.0, unicode-segmentation 1.13.3, unicode-width 0.2.2
----------------------------------------------------------------------------
@@ -8831,30 +8743,6 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE) applies to: libsqlite3-sys 0.38.2, rusqlite 0.40.2
----------------------------------------------------------------------------
Copyright (c) 2014 The rusqlite developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
----------------------------------------------------------------------------
The following license (COPYRIGHT) applies to: linux-raw-sys 0.12.1
----------------------------------------------------------------------------
@@ -9178,6 +9066,26 @@ the following restrictions:
3. This notice may not be removed or altered from any source distribution.
----------------------------------------------------------------------------
The following license (LUCIDE-LICENSE) applies to: Lucide 0.462.0 (icon path data, crates/pf-console-ui)
----------------------------------------------------------------------------
ISC License
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-APACHE2.0) applies to: mac_address 1.1.8
----------------------------------------------------------------------------
@@ -12639,32 +12547,6 @@ OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-MIT) applies to: roxmltree 0.21.1
----------------------------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2018 Yevhenii Reizner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE-MIT.md) applies to: rpkg-config 0.1.2
----------------------------------------------------------------------------
@@ -13768,32 +13650,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE) applies to: sqlite-wasm-rs 0.5.5
----------------------------------------------------------------------------
MIT License
Copyright (c) 2024 Spxg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
----------------------------------------------------------------------------
The following license (LICENSE) applies to: strsim 0.11.1
----------------------------------------------------------------------------
+211 -2
View File
@@ -10,9 +10,117 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.2"
"version": "0.34.0"
},
"paths": {
"/api/v1/actions": {
"get": {
"tags": [
"actions"
],
"summary": "List host actions",
"description": "The actions this host offers, as seen by the caller: platform availability (with the honest\nreason when something can't run) and whether THIS caller is permitted to invoke each one.\nAdmin lane: everything permitted. Paired-cert lane: permission follows the device's live\naccess mask (the Host power grant). Clients render rows generically — unknown ids still\nwork with the server-supplied title.",
"operationId": "listActions",
"responses": {
"200": {
"description": "The actions, per-caller",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ActionList"
}
}
}
},
"401": {
"description": "Missing or invalid credentials",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/actions/{id}": {
"post": {
"tags": [
"actions"
],
"summary": "Invoke a host action",
"description": "Runs one action by id — empty body, no parameters: the id selects a fixed host-side\nbehavior, and nothing in the request reaches the privileged path. On `202` the host first\nends every streaming session cleanly (clients see a typed \"the host is going to sleep /\nshutting down\" close), waits ~1 s so this response flushes, then acts.\n\nPaired-cert callers need the **Host power** grant, and are refused (`409`) while another\ndevice's session is live — a granted guest cannot yank the host out from under the owner\nmid-stream. The admin console is never blocked (it warns instead). One action runs at a\ntime host-wide.",
"operationId": "invokeAction",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Action id (`power.sleep`, `power.reboot`, `power.shutdown`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"202": {
"description": "Accepted — sessions are being ended and the action follows in about a second"
},
"401": {
"description": "Missing or invalid credentials",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"403": {
"description": "This caller's access does not include this action (no Host power grant)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Unknown action id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Refused: an action is already in flight, another device's session is live (cert lane), or the platform said no (a foreign sleep inhibitor, a second local user, …)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"501": {
"description": "This host platform has no executor for it (macOS host)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/client-logs": {
"get": {
"tags": [
@@ -4331,6 +4439,67 @@
},
"components": {
"schemas": {
"ActionInfo": {
"type": "object",
"description": "One action as the caller sees it (`GET /actions`).",
"required": [
"id",
"title",
"group",
"danger",
"available",
"permitted"
],
"properties": {
"available": {
"type": "boolean",
"description": "Whether this host can run it right now (platform probe — a VM that can't S3 lists\nsleep as unavailable rather than offering a dead switch)."
},
"danger": {
"type": "boolean",
"description": "Whether a client UI should double-confirm (the action loses state — reboot/shutdown)."
},
"group": {
"type": "string",
"description": "Action group (`power` for the built-ins)."
},
"id": {
"type": "string",
"description": "Stable action id (`power.sleep`, …) — the invoke path parameter.",
"example": "power.sleep"
},
"permitted": {
"type": "boolean",
"description": "Whether THIS caller may invoke it (admin lane: always; cert lane: the `GRANT_POWER`\nbit of the device's live access mask)."
},
"title": {
"type": "string",
"description": "Display title. Clients localize known ids and fall back to this for unknown ones."
},
"unavailable_reason": {
"type": [
"string",
"null"
],
"description": "Why it is unavailable, when it is."
}
}
},
"ActionList": {
"type": "object",
"description": "`GET /actions` response.",
"required": [
"actions"
],
"properties": {
"actions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ActionInfo"
}
}
}
},
"ActiveGame": {
"type": "object",
"description": "One launched game, for the console's running-game card.",
@@ -5535,7 +5704,7 @@
},
"pnp_disable_monitors": {
"type": "boolean",
"description": "EXPERIMENTAL (Windows): DISABLE physical monitors' PnP device nodes for the stream's\nduration (persistently, so a standby monitor/TV whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`\nisolate deactivated, plus — in ANY topology — external monitors that are connected but not\npart of the desktop (the standby TV that was never active, whose input auto-scan /\ninstant-on HPD cycling re-probes the link every few seconds). Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off."
"description": "EXPERIMENTAL (Windows): DISABLE the OPERATOR'S OWN physical monitors' PnP device nodes for\nthe stream's duration (persistently, so a monitor whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown the monitors an `Exclusive` isolate\ndeactivated. Still opt-in, because it takes displays the operator was actually using.\n\nThe *other* selector — external monitors connected but part of NO topology (the standby\nTV that was never active, whose input auto-scan / instant-on HPD cycling re-probes the\nlink every few seconds) — no longer needs this flag: it runs by default, see\n[`standby_sink_neutralise`]. Setting this flag still implies it. Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off."
},
"preset": {
"$ref": "#/components/schemas/Preset"
@@ -6141,6 +6310,42 @@
}
}
},
{
"type": "object",
"description": "A host action was invoked (`design/host-actions.md` §3.3) — v1: the `power.*` verbs.\nEmitted on ACCEPT (`outcome: \"accepted\"`), and again if the executor later fails\n(`outcome: \"failed: …\"`) — a succeeded power action ends this process, so \"accepted with\nno failure after it\" is the success signal a hook can act on (\"the host is going down\").",
"required": [
"id",
"outcome",
"kind"
],
"properties": {
"device": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/DeviceRef",
"description": "The invoking paired device, when the cert lane invoked it; absent for the\noperator's console (admin lane)."
}
]
},
"id": {
"type": "string",
"description": "The invoked action id (`power.sleep`, `power.reboot`, `power.shutdown`)."
},
"kind": {
"type": "string",
"enum": [
"action.invoked"
]
},
"outcome": {
"type": "string",
"description": "`accepted`, or `failed: <the executor's error>`."
}
}
},
{
"type": "object",
"required": [
@@ -9142,6 +9347,10 @@
{
"name": "update",
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
},
{
"name": "actions",
"description": "Host actions: discover what this host offers (per-caller availability + permission) and invoke one by id — v1: sleep, restart, shut down the machine, gated per device by the Host power grant"
}
]
}
+37
View File
@@ -0,0 +1,37 @@
# Lucide icon masters
The canonical UI marks every client draws its own icons from — the quick-action ring's slots,
and the ordinary shell chrome (back, refresh, save, delete…) on the desktop clients.
[Lucide](https://lucide.dev) **v0.462.0**, ISC licensed (see `THIRD-PARTY-NOTICES.txt`), fetched
unmodified from `lucide-icons/lucide` at that tag. One file per icon, its own name. Every master
is a 24×24 `viewBox`, `fill="none"`, `stroke="currentColor"`, `stroke-width="2"`, round caps and
joins — Lucide's own drawing contract, and what every derivative below reproduces.
## Which client consumes what
`scripts/gen-lucide-assets.sh` derives all of it. Nothing here is hand-edited.
| client | form | where |
|---|---|---|
| Skia console (gamepad UI) | folded path string, stroked by Skia | `crates/pf-client-core/src/lucide.rs``crates/pf-console-ui/src/icons.rs` |
| GTK shell | the same path string, stroked by `gsk::Path` | `crates/pf-client-core/src/lucide.rs` |
| WinUI shell | PNG, baked twice (grey and white) | `clients/windows/assets/lucide/` |
The two Rust consumers share **one** table, so a mark cannot differ between the console and the
GTK shell. The WinUI shell bakes because windows-reactor has no vector element: its `Image` takes
a raster URI, and its `BitmapIcon` is created with `ShowAsMonochrome(false)`, so a WinUI icon
cannot be tinted at runtime and has to ship in the colour it will be drawn in. Hence two bakes —
`lucide/` in mid-grey for ordinary surfaces, `lucide-on/` in white for accent buttons and the
ring's dark discs.
## Adding an icon
1. Drop the master here: `curl -o assets/lucide/<name>.svg
https://raw.githubusercontent.com/lucide-icons/lucide/0.462.0/icons/<name>.svg`
2. `bash scripts/gen-lucide-assets.sh`
3. Add the PNG to `clients/windows/src/app/lucide.rs`'s `ICONS` table — the shipped-token list,
the same discipline the OS marks and launcher marks keep.
The console's `icons.rs` and the GTK shell need no list of their own: both read
`pf_client_core::lucide`, which the script regenerates whole.
+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>

After

Width:  |  Height:  |  Size: 262 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M18 17V9" />
<path d="M13 17V5" />
<path d="M8 17v-3" />
</svg>

After

Width:  |  Height:  |  Size: 320 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 239 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 236 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 238 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m18 15-6-6-6 6" />
</svg>

After

Width:  |  Height:  |  Size: 238 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>

After

Width:  |  Height:  |  Size: 339 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 10 4 15 9 20" />
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</svg>

After

Width:  |  Height:  |  Size: 286 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</svg>

After

Width:  |  Height:  |  Size: 312 B

File diff suppressed because it is too large Load Diff
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="6" x2="10" y1="11" y2="11" />
<line x1="8" x2="8" y1="9" y2="13" />
<line x1="15" x2="15.01" y1="12" y2="12" />
<line x1="18" x2="18.01" y1="10" y2="10" />
<path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z" />
</svg>

After

Width:  |  Height:  |  Size: 680 B

+21
View File
@@ -0,0 +1,21 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 461 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" x2="9" y1="12" y2="12" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>

After

Width:  |  Height:  |  Size: 366 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 332 B

+18
View File
@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="2" x2="22" y1="2" y2="22" />
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
<path d="M5 10v2a7 7 0 0 0 12 5" />
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 471 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 361 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 258 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="6 3 20 12 6 21 6 3" />
</svg>

After

Width:  |  Height:  |  Size: 250 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>

After

Width:  |  Height:  |  Size: 256 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 14a8 8 0 0 1-8 8" />
<path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2" />
<path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1" />
<path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10" />
<path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" />
</svg>

After

Width:  |  Height:  |  Size: 521 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</svg>

After

Width:  |  Height:  |  Size: 276 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>

After

Width:  |  Height:  |  Size: 392 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>

After

Width:  |  Height:  |  Size: 299 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
<path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" />
<path d="M7 3v4a1 1 0 0 0 1 1h7" />
</svg>

After

Width:  |  Height:  |  Size: 417 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" />
<path d="m21.854 2.147-10.94 10.939" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 824 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>

After

Width:  |  Height:  |  Size: 420 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
<path d="M16 9a5 5 0 0 1 0 6" />
<path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
</svg>

After

Width:  |  Height:  |  Size: 476 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>

After

Width:  |  Height:  |  Size: 260 B

+13 -3
View File
@@ -46,16 +46,26 @@ ENV RUSTUP_HOME=/usr/local/rustup \
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
&& cargo install cargo-ndk --locked \
# Version-pinned like every other tool baked in here: unpinned, a rebuild months apart
# silently bakes a different cargo-ndk, and this one drives the shipped Android .so builds.
# crates.io is append-only with a checksummed index, so the version IS the pin. Bump freely.
&& cargo install cargo-ndk@4.1.2 --locked \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo ndk --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
+19 -10
View File
@@ -53,21 +53,30 @@ RUN pacman -Syu --noconfirm --needed \
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
# and its `-static-libstdc++` link is untouched.
mold \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
# as their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so
# bootstrap the official binary — once, here, instead of per run.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
&& rm -rf /root/.bun \
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1) — so these bytes end up inside the
# package arch.yml signs and publishes. Arch ships bun in [extra], so take the
# pacman-signed package (pacman verifies package signatures by default) instead of piping
# bun.sh's installer into root's shell, which would be upstream code choosing them. Same
# call as arch.yml's bootstrap guard. It rides THIS transaction rather than a later layer
# on purpose: -Syu refreshes the db in the same step that installs, so a cache-hit rebuild
# can never resolve bun against a stale snapshot the mirrors no longer carry.
bun \
&& pacman -Scc --noconfirm \
&& bun --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
+27 -6
View File
@@ -17,8 +17,8 @@ RUN dnf -y install \
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
&& dnf -y install \
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
# — the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun
# installer.
# — the punktfunk-web console builds AND runs on bun (installed below); unzip extracts the
# pinned bun zip.
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
# build toolchain + bindgen
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
@@ -43,8 +43,22 @@ RUN dnf -y install \
# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
# RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a
# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`. The spec
# VENDORS this very binary into punktfunk-web, so the installer would be upstream code choosing
# bytes rpm.yml then signs with RPM_GPG_PRIVATE_KEY. ONE bun across the repo: same version, asset
# and sum as rpm.yml, deb.yml and rust-ci.Dockerfile — bump BUN_VERSION and BUN_SHA together (the
# sums are in the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun
# we ship starts on every x86-64 box — something the auto-detecting installer never promised, since
# it reads the BUILDER's CPU, not the user's.
ARG BUN_VERSION=1.3.14
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
RUN curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
&& install -m0755 /tmp/bun /usr/local/bin/bun \
&& rm -f /tmp/bun.zip /tmp/bun \
&& bun --version
# libcuda link stub — the zerocopy path links a fixed set of cuXxx driver symbols, but CI has
@@ -78,9 +92,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
+26 -3
View File
@@ -47,7 +47,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
# (n8.0 -> libavcodec 62); bump it to move FFmpeg — together with the commit SHA it is pinned to below.
#
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
@@ -57,16 +57,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
ARG FFMPEG_TAG=n8.0
# The COMMIT that tag points at. A git tag is MUTABLE — upstream can move one, and unlike a branch
# nobody would notice — and these .so's are BUNDLED into the host .deb every Ubuntu user installs.
# The clone below asserts HEAD against this, so a moved tag fails the build loudly instead of
# shipping. Same shape as the bun/sccache sha256 pins: a mismatch stops the build, it does not
# silently "fix" itself. Bump alongside FFMPEG_TAG:
# git ls-remote --tags https://github.com/FFmpeg/FFmpeg.git 'refs/tags/<new-tag>^{}'
# Take the `^{}` line: these are ANNOTATED tags, so the bare ref is the tag OBJECT and the peeled
# `^{}` is the commit — the commit is what a clone leaves at HEAD, and what this compares against.
ARG FFMPEG_SHA=140fd653aed8cad774f991ba083e2d01e86420c7
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
ARG NVHDR_TAG=n12.2.72.0
# Commit for NVHDR_TAG, asserted after checkout — see FFMPEG_SHA above for why and how to bump:
# git ls-remote --tags https://github.com/FFmpeg/nv-codec-headers.git 'refs/tags/<new-tag>^{}'
ARG NVHDR_SHA=c69278340ab1d5559c7d7bf0edf615dc33ddbba7
RUN set -eux; \
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
test "$(git -C /tmp/nvhdr rev-parse HEAD)" = "$NVHDR_SHA" \
|| { echo "error: nv-codec-headers $NVHDR_TAG is not $NVHDR_SHA — tag moved upstream" >&2; exit 1; }; \
make -C /tmp/nvhdr install PREFIX=/usr/local; \
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
test "$(git -C /tmp/ffmpeg rev-parse HEAD)" = "$FFMPEG_SHA" \
|| { echo "error: FFmpeg $FFMPEG_TAG is not $FFMPEG_SHA — tag moved upstream" >&2; exit 1; }; \
cd /tmp/ffmpeg; \
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
--prefix=/opt/ffmpeg \
@@ -98,9 +114,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
+26 -5
View File
@@ -11,7 +11,7 @@
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip extracts the pinned bun zip
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
@@ -34,8 +34,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
# ci.yml's web/docs jobs use the oven/bun image instead, so this is only for the deb job.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb, so the installer would be
# upstream code choosing bytes a signing job then publishes. ONE bun across the repo: same version,
# asset and sum as deb.yml and rpm.yml — bump BUN_VERSION and BUN_SHA together (the sums are in the
# release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we ship starts on
# every x86-64 box — something the auto-detecting installer never promised, since it reads the
# BUILDER's CPU, not the user's.
ARG BUN_VERSION=1.3.14
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
RUN curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
&& install -m0755 /tmp/bun /usr/local/bin/bun \
&& rm -f /tmp/bun.zip /tmp/bun \
&& bun --version
# libcuda link stub: the NVIDIA userspace library (no kernel module needed) provides
@@ -60,9 +74,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
@@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import io.unom.punktfunk.models.PendingLinkConnect
import io.unom.punktfunk.models.PendingTrust
// The touch UI's prompts, each described once — a title, a list of [DialogAction]s (primary
@@ -165,6 +166,36 @@ fun RequestAccessPrompt(
}
}
/**
* A `punktfunk://` link that named a saved host by its label or its address rather than by its
* stable id: both are guessable, and the activity is exported, so the dial happens on the user's
* tap instead of on the link's say-so. A link that names the id every shortcut Punktfunk itself
* emits never reaches this prompt.
*/
@Composable
fun LinkConnectPrompt(
target: PendingLinkConnect,
onConnect: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
title = "Open this link?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Connect", primary = true, onClick = onConnect),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("A link asks to connect to ${target.host.name} (${target.host.address}).")
target.launch?.let { PromptText("It also asks the host to launch “$it”.") }
PromptText(
"It names the host by its label or address, which anything that can open a link " +
"could guess. Shortcuts made in Punktfunk name the host's id and connect " +
"without asking.",
)
}
}
/**
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
* this device. Cancel returns the UI immediately the caller trips the per-attempt flag so a late
@@ -144,7 +144,7 @@ fun App(forceGamepadUi: Boolean = false) {
activity.pendingDeepLink = null
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
val sameHost = target is HostResolution.Record && target.host.id == live.hostId
if (!sameHost) {
Toast.makeText(
context,
@@ -309,3 +309,33 @@ internal fun EditHostDialog(
},
)
}
/**
* "Restart host?" / "Shut down host?" the confirmation a destructive host action takes before
* it runs (`design/host-actions.md` §7). Sleep is reversible from the same menu ("Wake host"),
* so it never reaches here; restart and shut down lose whatever is on that machine, so they do.
*/
@Composable
internal fun HostActionConfirmDialog(
hostName: String,
action: HostActions.Action,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("${action.label}?") },
text = {
Text(
"This ends every stream from $hostName and anything running on it. " +
"You'll need to wake or start it again.",
)
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(action.label) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -58,6 +58,11 @@ object ConnectErrors {
"launch-not-permitted" ->
"This device's access doesn't include launching games — connect to the desktop, " +
"or ask the host's owner."
// A host power action (design/host-actions.md) ended the session deliberately.
// Without this arm it falls through to the generic failure, and sleeping your own
// host from the couch reads as a crash.
"host-power" ->
"The host is going to sleep or shutting down — wake it when you want to play again."
else -> null
}
@@ -80,6 +80,12 @@ internal fun ConnectGrid(
onEdit: (KnownHost) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
/** Upload this device's recent log to the host — see the menu row's gate below. */
onSendLogs: (KnownHost) -> Unit,
/** What each paired host last said this device may do TO it, by fingerprint
* (`design/host-actions.md` §7). Absent = no rows. */
hostActions: Map<String, List<HostActions.Action>>,
onHostAction: (KnownHost, HostActions.Action) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
onTogglePin: (KnownHost, StreamProfile) -> Unit,
/** The experimental game-library toggle — off hides "Browse library…" everywhere. */
@@ -108,6 +114,25 @@ internal fun ConnectGrid(
if (pin == null) {
add(HostMenuItem("Network speed test") { onSpeedTest(kh) })
}
// "Send logs to host" — the same row the console's host menu carries
// (`pf-console-ui`'s `options.rs`), on the same gate: the upload authenticates with the
// streaming cert, so it needs a paired identity and a host that is answering. It belongs
// HERE too and not only in the console: a device whose console never comes up is exactly
// the one whose logs somebody needs, and the touch home was its only shell.
if (pin == null && kh.paired && kh.isOnline(discovered, reachable)) {
add(HostMenuItem("Send logs to host") { onSendLogs(kh) })
}
// The host's own actions — sleep, restart, shut it down (`design/host-actions.md` §7),
// the other half of the Wake-on-LAN round trip. Nothing is decided here: the list is
// empty unless the host answered AND this device's access carries the grant, so no row
// appears that the host would refuse. A pinned card is a shortcut to one profile, not a
// second host, so it offers none — same rule as "Send logs" above.
if (pin == null) {
hostActions[kh.fpHex].orEmpty().forEach { a ->
val label = if (a.available) a.label else "${a.label} (unavailable)"
add(HostMenuItem(label) { onHostAction(kh, a) })
}
}
add(HostMenuItem("Copy link") { onCopyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
@@ -3,12 +3,14 @@ package io.unom.punktfunk
import androidx.compose.runtime.Composable
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingLinkConnect
import io.unom.punktfunk.models.PendingTrust
/**
* Everything `ConnectScreen` puts ON TOP of whichever home it drew the trust and pairing
* ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the
* edit form, the local-network rationale, and finally the connect takeover.
* ceremony, a link's connect confirmation, the parked "Waiting for approval…", the console's host
* options, the speed test, the edit form, the local-network rationale, and finally the connect
* takeover.
*
* They live together because their ORDER is the contract: this is a stack of siblings in one tree,
* so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose a dial can
@@ -33,6 +35,11 @@ internal fun ConnectPrompts(
/** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */
onPaired: (PendingTrust, String) -> Unit,
onRequestAccess: (PendingTrust) -> Unit,
// ---- a link that named a saved host by a guessable reference ----------------------------
/** Non-null while such a link waits for the OK that turns it into a plain dial. */
pendingLinkConnect: PendingLinkConnect?,
onConfirmLinkConnect: (PendingLinkConnect) -> Unit,
onDismissLinkConnect: () -> Unit,
// ---- the parked no-PIN request ----------------------------------------------------------
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
awaitingHostName: String?,
@@ -89,6 +96,14 @@ internal fun ConnectPrompts(
}
}
pendingLinkConnect?.let { plc ->
LinkConnectPrompt(
target = plc,
onConnect = { onConfirmLinkConnect(plc) },
onDismiss = onDismissLinkConnect,
)
}
awaitingHostName?.let { hostLabel ->
AwaitingApprovalPrompt(hostLabel = hostLabel, onCancel = onCancelApproval)
}
@@ -38,6 +38,7 @@ import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.PendingLinkConnect
import io.unom.punktfunk.models.PendingTrust
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Dispatchers
@@ -52,6 +53,14 @@ import kotlinx.coroutines.withContext
*/
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
/**
* How long a host's advertised actions stay fresh before this screen asks again the desktop's
* `pf_client_core::host_actions::TTL`. Long on purpose: what it governs (whether this device
* holds the Host-power grant, whether the box can suspend) changes when an operator edits
* access, not minute to minute, and every refresh is a TLS handshake against an idle host.
*/
private const val HOST_ACTIONS_TTL_MS = 300_000L
/**
* A no-PIN "request access" connect in flight the host being requested (drives the cancelable
* "Waiting for approval…" dialog) and a per-attempt flag the Cancel button trips. The connect is a
@@ -284,11 +293,45 @@ fun ConnectScreen(
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing / the
// request-access-or-PIN choice).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// A `punktfunk://` link that named a saved host by a guessable reference, awaiting the OK.
var pendingLinkConnect by remember { mutableStateOf<PendingLinkConnect?>(null) }
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
// A saved host being edited (name / address / port / MAC).
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
// What each paired host says this device may do TO it — sleep, restart, shut it down
// (`design/host-actions.md` §7) — by fingerprint, with the moment we last asked.
//
// Learned on a slow TTL rather than when a menu opens: the row list has to be settled BEFORE
// the menu draws, or rows would appear under a finger already on its way down, and two of
// these rows end whatever is running on that machine. Empty for an older host (no such
// route), an unreachable one, and any device without the grant — the menu simply has no
// power rows then.
var hostActions by remember { mutableStateOf<Map<String, List<HostActions.Action>>>(emptyMap()) }
var hostActionsAt by remember { mutableStateOf<Map<String, Long>>(emptyMap()) }
val reachableNow by rememberUpdatedState(reachable)
LaunchedEffect(savedHosts, identity) {
val id = identity ?: return@LaunchedEffect
while (true) {
val now = android.os.SystemClock.elapsedRealtime()
for (kh in savedHosts) {
if (!kh.paired || kh.fpHex.isEmpty()) continue
if (!kh.isOnline(discoveredNow, reachableNow)) continue
if (now - (hostActionsAt[kh.fpHex] ?: 0L) < HOST_ACTIONS_TTL_MS) continue
// Stamp BEFORE the request, so a slow host cannot make every lap ask again.
hostActionsAt = hostActionsAt + (kh.fpHex to now)
val found = withContext(Dispatchers.IO) {
HostActions.list(id, kh.address, kh.effectiveMgmtPort, kh.fpHex)
}
hostActions = hostActions + (kh.fpHex to found)
}
delay(30_000)
}
}
// A destructive host action awaiting its confirmation (restart / shut down).
var confirmAction by remember { mutableStateOf<Pair<KnownHost, HostActions.Action>?>(null) }
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
@@ -632,6 +675,56 @@ fun ConnectScreen(
if (copied) notice = message else status = message
}
// Host actions (`design/host-actions.md` §7) — sleep, restart or shut the host down. The
// menu rows come from what the HOST said it lets this device do, so a device without the
// Host-power grant is offered none; a destructive one still asks first, because losing what
// is running on that machine is not something a mis-tap should be able to do.
fun runHostAction(kh: KnownHost, a: HostActions.Action) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
}
val name = kh.name.ifBlank { kh.address }
notice = "${a.label} — asking $name"
status = null
// Whatever the host said about itself is about to be wrong: ask again next sweep.
hostActionsAt = hostActionsAt - kh.fpHex
scope.launch {
notice = withContext(Dispatchers.IO) {
HostActions.invoke(
id, kh.address, kh.effectiveMgmtPort, kh.fpHex, name, a.id, a.label,
)
}
}
}
fun hostAction(kh: KnownHost, a: HostActions.Action) {
when {
// The host already said it cannot do this right now — say why, rather than send a
// request we know it will refuse.
!a.available ->
notice = a.unavailableReason.ifEmpty { "${a.label} isn't available right now" }
a.danger -> confirmAction = kh to a
else -> runHostAction(kh, a)
}
}
// "Send logs to host" — [SendLogs], the same upload the console's host menu runs. The outcome
// is a notice either way (success and failure both name the host), because the row's whole job
// is to tell a reporter whether the bundle actually landed.
fun sendLogs(kh: KnownHost) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
}
notice = "Sending logs to ${kh.name.ifBlank { kh.address }}…"
status = null
scope.launch {
val message = withContext(Dispatchers.IO) { SendLogs.toHost(context, id, kh) }
notice = message
}
}
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
//
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
@@ -673,8 +766,10 @@ fun ConnectScreen(
}
}
when (val resolved = DeepLinks.resolveHost(link, savedHosts)) {
// Known AND pinned is the one-click contract: do exactly what tapping its card does.
is HostResolution.Known -> {
// A saved record. Pinned AND named by its (unguessable) id is the one-click contract:
// do exactly what tapping its card does. Named by anything a web page could guess —
// its label, its address — the same dial waits for a tap on the confirmation.
is HostResolution.Record -> {
// A pin that contradicts the stored one is the link being stale or lying. Hard
// refusal: this is the one case where doing what the card does would be wrong.
if (link.pinConflict(resolved.host)) {
@@ -691,6 +786,10 @@ fun ConnectScreen(
)
return@LaunchedEffect
}
if (resolved is HostResolution.Confirm) {
pendingLinkConnect = PendingLinkConnect(resolved.host, profileRef, link.launch)
return@LaunchedEffect
}
connect(
resolved.host.address, resolved.host.port,
oneOffProfile = profileRef, launch = link.launch,
@@ -767,6 +866,9 @@ fun ConnectScreen(
onEdit = { kh -> editTarget = kh },
onWake = { kh -> wakeHost(kh) },
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
onSendLogs = { kh -> sendLogs(kh) },
hostActions = hostActions,
onHostAction = { kh, a -> hostAction(kh, a) },
onCopyLink = { kh, pin -> copyLink(kh, pin) },
onTogglePin = { kh, p -> togglePin(kh, p) },
libraryEnabled = settings.libraryEnabled,
@@ -802,6 +904,18 @@ fun ConnectScreen(
val editSuggestedMacs =
editTarget?.let { kh -> discovered.firstOrNull { kh.matches(it) }?.mac } ?: emptyList()
// A destructive host action's confirmation. Kept here rather than in ConnectPrompts because
// it is a one-question dialog owned by the row that raised it — the same place the row's
// handler lives.
confirmAction?.let { (kh, a) ->
HostActionConfirmDialog(
hostName = kh.name.ifBlank { kh.address },
action = a,
onConfirm = { confirmAction = null; runHostAction(kh, a) },
onDismiss = { confirmAction = null },
)
}
// Everything that floats above whichever home was drawn, in one place and in one order — see
// ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above.
ConnectPrompts(
@@ -821,6 +935,15 @@ fun ConnectScreen(
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
},
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
pendingLinkConnect = pendingLinkConnect,
onConfirmLinkConnect = { plc ->
pendingLinkConnect = null
connect(
plc.host.address, plc.host.port,
oneOffProfile = plc.profile, launch = plc.launch,
)
},
onDismissLinkConnect = { pendingLinkConnect = null },
awaitingHostName = awaiting?.target?.name,
onCancelApproval = {
awaiting?.cancelled?.set(true)
@@ -932,6 +932,10 @@ private val TEST_BUTTONS = listOf(
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
"Start" to KeyEvent.KEYCODE_BUTTON_START,
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
// The two buttons Android has no keycode for, on the keycodes [Gamepad.buttonBit] borrows for
// them. Only a driverless Sony pad reaches these; every other controller leaves them dark.
"Touch" to KeyEvent.KEYCODE_BUTTON_15,
"Mute" to KeyEvent.KEYCODE_BUTTON_16,
"" to KeyEvent.KEYCODE_DPAD_UP,
"" to KeyEvent.KEYCODE_DPAD_DOWN,
"" to KeyEvent.KEYCODE_DPAD_LEFT,
@@ -0,0 +1,121 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.security.ClientIdentity
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/**
* Host actions sleep, restart or shut down a paired host from this device
* (`design/host-actions.md` §7), over the same mTLS identity the library fetch and the log
* upload use.
*
* The HOST is the only enforcer: [Action.permitted] is what it says about *this* device's
* access, so a device without the Host-power grant is offered nothing rather than shown a row
* that will be refused. Discovery is best-effort by contract an older host (no such route),
* an unreachable one, or a shape we don't recognise yields an empty list, because a missing
* menu row costs a menu row and a thrown exception costs the screen.
*
* ONE implementation for both Android shells, like [SendLogs]: the Skia console's host menu and
* the touch home's card menu. Wording is the desktop's verbatim (`pf_client_core::host_actions`)
* so a quoted message means the same thing on every client.
*
* Blocking call it off the main thread.
*/
object HostActions {
/** One action as the host reports it to THIS device. */
data class Action(
/** Stable id, the invoke argument (`power.sleep`). */
val id: String,
/** This client's wording for a known id, else the host's own title. */
val label: String,
/** Confirm twice — the action loses whatever is running on that machine. */
val danger: Boolean,
/** The host can run it right now. */
val available: Boolean,
/** Why not, when it can't. Empty otherwise. */
val unavailableReason: String,
)
/** Local wording for the ids we know; anything else keeps the host's own title, which is
* what lets a later host add an action with no client release. */
private fun label(id: String, title: String): String = when (id) {
"power.sleep" -> "Sleep host"
"power.reboot" -> "Restart host"
"power.shutdown" -> "Shut down host"
else -> title
}
/**
* What this host lets this device do to it (`GET /api/v1/actions`). Only the PERMITTED rows
* come back: what a device may not invoke is not its business to render.
*/
fun list(identity: ClientIdentity, addr: String, mgmtPort: Int, fpHex: String): List<Action> =
runCatching {
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
identity.certPem, identity.privateKeyPem, addr, fpHex,
)
val req = Request.Builder().url("https://$addr:$mgmtPort/api/v1/actions").get().build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@runCatching emptyList()
val arr = JSONObject(resp.body?.string().orEmpty()).optJSONArray("actions")
?: return@runCatching emptyList()
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
if (!o.optBoolean("permitted")) return@mapNotNull null
val id = o.optString("id")
Action(
id = id,
label = label(id, o.optString("title")),
danger = o.optBoolean("danger"),
available = o.optBoolean("available"),
unavailableReason = o.optString("unavailable_reason"),
)
}
}
}.getOrDefault(emptyList())
/**
* Invoke one action by id (`POST /api/v1/actions/{id}`, empty body) and return the
* user-facing outcome.
*
* A 202 is the last word: the host ends every session and acts about a second later, so
* there is nothing to poll and nothing to undo. A refusal carries the host's own reason
* ("another device is streaming from this host right now"), which tells a person what to do
* where a bare status code would not.
*/
fun invoke(
identity: ClientIdentity,
addr: String,
mgmtPort: Int,
fpHex: String,
hostName: String,
actionId: String,
label: String,
): String {
val err = runCatching {
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
identity.certPem, identity.privateKeyPem, addr, fpHex,
)
val req = Request.Builder()
.url("https://$addr:$mgmtPort/api/v1/actions/$actionId")
// Empty body by design: the id is the whole request, and no request field ever
// reaches the host's privileged path.
.post(ByteArray(0).toRequestBody(null, 0, 0))
.build()
client.newCall(req).execute().use { resp ->
if (resp.isSuccessful) {
""
} else {
// The `ApiError` envelope carries the host's sentence; fall back to the code
// only when there isn't one.
runCatching {
JSONObject(resp.body?.string().orEmpty()).optString("error")
}.getOrNull()?.takeIf { it.isNotEmpty() } ?: "the host answered HTTP ${resp.code}"
}
}
}.getOrElse { it.message ?: "the host could not be reached" }
return if (err.isEmpty()) "$hostName: $label — on its way" else "$label failed — $err"
}
}
@@ -101,6 +101,9 @@ suspend fun connectToHost(
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
// "Keep host audio playing": the host taps its own default output rather than
// silencing it for the session. Free to ask for — an older host just ignores it.
settings.keepHostAudio,
)
}
}
@@ -628,7 +628,7 @@ class MainActivity : ComponentActivity() {
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
// why this asks it rather than `buttonBit`).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
if (fromPad(event)) {
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
@@ -710,7 +710,7 @@ class MainActivity : ComponentActivity() {
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
lastPadIsGamepad = fromPad(event)
if (lastPadIsGamepad) {
lastPadStyle = Gamepad.styleFor(event.device)
lastPadDeviceId = event.deviceId
@@ -718,7 +718,7 @@ class MainActivity : ComponentActivity() {
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
if (fromPad(event)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
// already move focus on their own, so they fall through to super untouched. Read
@@ -741,6 +741,32 @@ class MainActivity : ComponentActivity() {
return super.dispatchKeyEvent(event)
}
/**
* Did this key event come from a controller the question every pad branch here actually
* means when it asks `isFromSource(SOURCE_GAMEPAD)`.
*
* The event's source class is the platform's per-EVENT guess, and some boxes get it wrong:
* Fire OS is reported to deliver a Bluetooth DualSense's Triangle, touchpad and Mode/PS with
* standard `KEYCODE_BUTTON_*` keycodes but a SOURCE_KEYBOARD tag, and the plain gate then
* drops them before anything can map them. The DEVICE's source classes are the fact, so widen
* to the device but only for keycodes that cannot be anything BUT a gamepad button.
*
* That restriction is the whole safety of this. [KeyEvent.isGamepadButton] is exactly the
* `KEYCODE_BUTTON_*` block no `KEYCODE_DPAD_*`, no `KEYCODE_BACK` and both exclusions are
* load-bearing: a keyboard's arrow keys share the D-pad keycodes and belong to the VK path
* ([Gamepad.buttonBit]), and a remote's or keyboard's BACK shares `KEYCODE_BACK` and has to
* keep leaving the stream, which for a device with no pad on it is the documented way out
* ([Gamepad.padButtonBit]). Widening on the device alone or on its vendor id, which for
* `0x045E`/`0x054C` covers those vendors' keyboards and mice too routes both into the pad
* branch and breaks them.
*
* The RAW keycode is what is asked: routing happens before [Gamepad.padKeyCode]'s correction,
* and both the raw and the corrected keycode are in this block for every button concerned.
*/
private fun fromPad(event: KeyEvent): Boolean =
event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
(KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device))
/**
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
* anything else including a remote's or keyboard's BACK, which must keep exiting the stream.
@@ -848,7 +874,7 @@ class MainActivity : ComponentActivity() {
val url = deepLinkFrom(intent) ?: return false
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all())
return target is HostResolution.Known && target.host.id == live.hostId
return target is HostResolution.Record && target.host.id == live.hostId
}
/** The host a live stream is on — see [liveStream]. */
@@ -0,0 +1,228 @@
package io.unom.punktfunk
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
/*
* The in-stream quick-action ring's configuration the `overlay_actions` setting, one JSON blob
* (schema v2, design/touch-client-overlay.md §3.2). The Kotlin twin of pf-client-core's
* `overlay_actions.rs`; the Rust tests are the contract, `OverlayActionsTest` ports them.
*
* Parsing never fails: fewer than six slots pad with empty, more are truncated, an unknown id or a
* dangling `shortcut:` reference is an empty slot, an absent field takes its default, and an
* unparseable blob is the platform default profiles sync between client versions.
*/
/** What a ring slot does. [Host] carries a host-advertised action id; [Shortcut] refers into
* [OverlayConfig.shortcuts] by id. */
sealed class SlotId {
object EndStream : SlotId()
object DisconnectLinger : SlotId()
object TouchMode : SlotId()
object Keyboard : SlotId()
object Stats : SlotId()
object Mic : SlotId()
object Pad : SlotId()
object SendText : SlotId()
data class Host(val actionId: String) : SlotId()
data class Shortcut(val shortcutId: String) : SlotId()
/** The wire id, the inverse of [parse]. */
val id: String
get() = when (this) {
EndStream -> "end_stream"
DisconnectLinger -> "disconnect_linger"
TouchMode -> "touch_mode"
Keyboard -> "keyboard"
Stats -> "stats"
Mic -> "mic"
Pad -> "pad"
SendText -> "send_text"
is Host -> "host:$actionId"
is Shortcut -> "shortcut:$shortcutId"
}
companion object {
/** An id from the blob; `null` for one this build does not know (an empty slot). */
fun parse(s: String): SlotId? = when (s) {
"end_stream" -> EndStream
"disconnect_linger" -> DisconnectLinger
"touch_mode" -> TouchMode
"keyboard" -> Keyboard
"stats" -> Stats
"mic" -> Mic
"pad" -> Pad
"send_text" -> SendText
else -> when {
s.startsWith("host:") && s.length > 5 -> Host(s.substring(5))
s.startsWith("shortcut:") && s.length > 9 -> Shortcut(s.substring(9))
else -> null
}
}
}
}
/** A custom key chord. [keys] are names from the shared keymap tables, never raw key codes. */
data class Shortcut(val id: String, val label: String = "", val keys: List<String> = emptyList())
/**
* The Windows virtual-key code a shortcut key name stands for (the wire speaks VKs); `null` for
* a name this build does not know. Twin of the Rust `key_vk`.
*/
fun keyVk(name: String): Int? {
val n = name.trim().lowercase()
return when (n) {
"ctrl", "control" -> 0x11
"shift" -> 0x10
"alt", "option" -> 0x12
"win", "cmd", "super", "meta" -> 0x5B
"escape", "esc" -> 0x1B
"tab" -> 0x09
"enter", "return" -> 0x0D
"space" -> 0x20
"backspace" -> 0x08
"delete", "del" -> 0x2E
"insert" -> 0x2D
"home" -> 0x24
"end" -> 0x23
"pageup" -> 0x21
"pagedown" -> 0x22
"up" -> 0x26
"down" -> 0x28
"left" -> 0x25
"right" -> 0x27
"printscreen" -> 0x2C
"pause" -> 0x13
"capslock" -> 0x14
else -> when {
n.length == 1 && n[0] in 'a'..'z' -> 0x41 + (n[0] - 'a')
n.length == 1 && n[0] in '0'..'9' -> 0x30 + (n[0] - '0')
n.length in 2..3 && n[0] == 'f' ->
n.substring(1).toIntOrNull()?.takeIf { it in 1..24 }?.let { 0x70 + it - 1 }
else -> null
}
}
}
/** A chord as a legend reads it: `Ctrl+Shift+Esc`. */
fun chordChip(keys: List<String>): String = keys.joinToString("+", transform = ::keyLegend)
/**
* One key's legend: the word a keyboard prints on it (`Ctrl`, `Esc`, `PgUp`), arrows as arrows.
* Symbols like or read as nothing to most people, so none are used here.
*/
fun keyLegend(k: String): String = when (k.trim().lowercase()) {
"ctrl", "control" -> "Ctrl"
"shift" -> "Shift"
"alt", "option" -> "Alt"
"win", "cmd", "super", "meta" -> "Win"
"escape", "esc" -> "Esc"
"enter", "return" -> "Enter"
"backspace" -> "Backspace"
"delete", "del" -> "Del"
"insert" -> "Ins"
"pageup" -> "PgUp"
"pagedown" -> "PgDn"
"printscreen" -> "PrtSc"
"capslock" -> "Caps"
"up" -> ""
"down" -> ""
"left" -> ""
"right" -> ""
else -> k.trim().lowercase().replaceFirstChar { it.uppercase() }
}
/** The virtual controller's preset: [layout] is `full`, `sticks` or `dpad`. */
data class PadConfig(val layout: String = "full", val opacity: Float = 0.45f, val scale: Float = 1f)
/** Which platform default ring applies. Android is always [TOUCH]; [DESKTOP] exists so the
* parser matches its twins exactly. */
enum class RingPlatform { TOUCH, DESKTOP }
data class OverlayConfig(
/** Exactly [RING_SLOTS] entries, clockwise from 12 o'clock; `null` is an empty slot. */
val ring: List<SlotId?>,
val shortcuts: List<Shortcut> = emptyList(),
val pad: PadConfig = PadConfig(),
) {
fun shortcut(id: String): Shortcut? = shortcuts.firstOrNull { it.id == id }
/** The blob to store — always the current schema version. */
fun toJson(): String {
val j = JSONObject()
j.put("v", SCHEMA_VERSION)
j.put("ring", JSONArray().also { arr -> ring.forEach { arr.put(it?.id ?: JSONObject.NULL) } })
j.put(
"shortcuts",
JSONArray().also { arr ->
shortcuts.forEach { s ->
arr.put(
JSONObject().put("id", s.id).put("label", s.label)
.put("keys", JSONArray(s.keys)),
)
}
},
)
j.put(
"pad",
JSONObject().put("layout", pad.layout).put("opacity", pad.opacity.toDouble())
.put("scale", pad.scale.toDouble()),
)
return j.toString()
}
companion object {
const val RING_SLOTS = 6
const val SCHEMA_VERSION = 2
fun platformDefault(platform: RingPlatform = RingPlatform.TOUCH): OverlayConfig = OverlayConfig(
ring = when (platform) {
RingPlatform.TOUCH -> listOf(
SlotId.EndStream, SlotId.Keyboard, SlotId.TouchMode,
SlotId.Stats, SlotId.Mic, SlotId.Pad,
)
RingPlatform.DESKTOP -> listOf(
SlotId.EndStream, SlotId.DisconnectLinger, SlotId.TouchMode,
SlotId.Stats, SlotId.Mic, SlotId.SendText,
)
},
)
/** Parse the setting; an empty or unparseable blob is the platform default. */
fun parse(json: String?, platform: RingPlatform = RingPlatform.TOUCH): OverlayConfig {
if (json.isNullOrBlank()) return platformDefault(platform)
val j = try {
JSONObject(json)
} catch (_: JSONException) {
return platformDefault(platform)
}
val shortcuts = buildList {
val arr = j.optJSONArray("shortcuts") ?: JSONArray()
for (i in 0 until arr.length()) {
val s = arr.optJSONObject(i) ?: continue
val id = s.optString("id", "")
if (id.isEmpty()) continue
val keys = s.optJSONArray("keys")?.let { k ->
(0 until k.length()).map { k.optString(it) }
} ?: emptyList()
add(Shortcut(id, s.optString("label", ""), keys))
}
}
val ringIn = j.optJSONArray("ring") ?: JSONArray()
val ring = (0 until RING_SLOTS).map { i ->
if (i >= ringIn.length() || ringIn.isNull(i)) return@map null
SlotId.parse(ringIn.optString(i))?.takeIf { slot ->
slot !is SlotId.Shortcut || shortcuts.any { it.id == slot.shortcutId }
}
}
val padIn = j.optJSONObject("pad")
val pad = PadConfig(
layout = padIn?.optString("layout", "full")?.ifEmpty { "full" } ?: "full",
opacity = padIn?.optDouble("opacity", 0.45)?.toFloat() ?: 0.45f,
scale = padIn?.optDouble("scale", 1.0)?.toFloat() ?: 1f,
)
return OverlayConfig(ring, shortcuts, pad)
}
}
}
@@ -45,9 +45,13 @@ data class SettingsOverlay(
val audioFormat: String? = null,
val micEnabled: Boolean? = null,
val echoCancel: Boolean? = null,
val keepHostAudio: Boolean? = null,
val touchMode: TouchMode? = null,
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
/** The whole ring blob (design/touch-client-overlay.md D10): a profile inherits the default
* ring entirely or owns its own ring and shortcuts. */
val overlayActions: String? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val systemButtons: String? = null,
@@ -82,9 +86,11 @@ data class SettingsOverlay(
audioFormat = audioFormat ?: base.audioFormat,
micEnabled = micEnabled ?: base.micEnabled,
echoCancel = echoCancel ?: base.echoCancel,
keepHostAudio = keepHostAudio ?: base.keepHostAudio,
touchMode = touchMode ?: base.touchMode,
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
overlayActions = overlayActions ?: base.overlayActions,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
systemButtons = systemButtons ?: base.systemButtons,
@@ -120,9 +126,13 @@ data class SettingsOverlay(
audioFormat = if (after.audioFormat != before.audioFormat) after.audioFormat else audioFormat,
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
keepHostAudio =
if (after.keepHostAudio != before.keepHostAudio) after.keepHostAudio else keepHostAudio,
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
overlayActions =
if (after.overlayActions != before.overlayActions) after.overlayActions else overlayActions,
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
@@ -152,9 +162,11 @@ data class SettingsOverlay(
"audio_format" -> copy(audioFormat = null)
"mic_enabled" -> copy(micEnabled = null)
"echo_cancel" -> copy(echoCancel = null)
"keep_host_audio" -> copy(keepHostAudio = null)
"touch_mode" -> copy(touchMode = null)
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
"overlay_actions" -> copy(overlayActions = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"system_buttons" -> copy(systemButtons = null)
@@ -179,9 +191,11 @@ data class SettingsOverlay(
if (audioFormat != null) add("audio_format")
if (micEnabled != null) add("mic_enabled")
if (echoCancel != null) add("echo_cancel")
if (keepHostAudio != null) add("keep_host_audio")
if (touchMode != null) add("touch_mode")
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
if (overlayActions != null) add("overlay_actions")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (systemButtons != null) add("system_buttons")
@@ -214,9 +228,11 @@ data class SettingsOverlay(
audioFormat?.let { j.put("audio_format", it) }
micEnabled?.let { j.put("mic_enabled", it) }
echoCancel?.let { j.put("echo_cancel", it) }
keepHostAudio?.let { j.put("keep_host_audio", it) }
touchMode?.let { j.put("touch_mode", it.name) }
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
overlayActions?.let { j.put("overlay_actions", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
systemButtons?.let { j.put("system_buttons", it) }
@@ -236,7 +252,8 @@ data class SettingsOverlay(
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "audio_format", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"keep_host_audio",
"touch_mode", "mouse_mode", "invert_scroll", "overlay_actions", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
@@ -255,11 +272,13 @@ data class SettingsOverlay(
audioFormat = j.optStringOrNull("audio_format"),
micEnabled = j.optBooleanOrNull("mic_enabled"),
echoCancel = j.optBooleanOrNull("echo_cancel"),
keepHostAudio = j.optBooleanOrNull("keep_host_audio"),
touchMode = j.optStringOrNull("touch_mode")
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
mouseMode = j.optStringOrNull("mouse_mode")
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
invertScroll = j.optBooleanOrNull("invert_scroll"),
overlayActions = j.optStringOrNull("overlay_actions"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
systemButtons = j.optStringOrNull("system_buttons"),
@@ -0,0 +1,497 @@
package io.unom.punktfunk
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
/**
* The quick-action ring's editor (design/touch-client-overlay.md §3.3): the editor IS the ring
* the in-stream [RingOverlay], the same composable, full size over a backdrop that runs the real
* twist ([streamTouchInput] with no session behind it). Tap a slot to pick its action from the
* catalogue, drag a disc onto another to swap, tap the centre to see depth two; the shortcuts
* list and the reset sit under it. A shortcut is edited on its own screen: a name, the modifiers
* as chips, the key on a keyboard you tap, and the disc as it will look. A deep sub-screen of
* Settings like [ControllersScreen]; [blob] is the `overlay_actions` of the layer being edited
* and [onChange] writes it back through the same `update` every row uses, so a profile that
* touches it owns the whole ring (D10).
*/
@Composable
internal fun QuickActionsScreen(
blob: String,
onChange: (String) -> Unit,
onReset: () -> Unit,
onBack: () -> Unit,
overridden: Boolean = false,
) {
val cfg = remember(blob) { OverlayConfig.parse(blob) }
var picking by remember { mutableStateOf<Int?>(null) }
var editingShortcut by remember { mutableStateOf<ShortcutDraft?>(null) }
fun set(k: Int, id: String) {
onChange(cfg.copy(ring = cfg.ring.toMutableList().also { it[k] = SlotId.parse(id) }).toJson())
}
fun swap(a: Int, b: Int) {
val ring = cfg.ring.toMutableList()
val t = ring[a]
ring[a] = ring[b]
ring[b] = t
onChange(cfg.copy(ring = ring).toJson())
}
fun save(d: ShortcutDraft) {
val sc = Shortcut(d.id, d.label, d.keys)
val i = cfg.shortcuts.indexOfFirst { it.id == d.id }
val next = if (i >= 0) {
cfg.copy(shortcuts = cfg.shortcuts.toMutableList().also { it[i] = sc })
} else {
val ring = cfg.ring.toMutableList()
ring.indexOf(null).takeIf { it >= 0 }?.let { ring[it] = SlotId.Shortcut(sc.id) }
cfg.copy(ring = ring, shortcuts = cfg.shortcuts + sc)
}
onChange(next.toJson())
}
fun remove(id: String) {
onChange(
cfg.copy(
// `parse` would empty a dangling slot on the next read; write it empty now so
// the ring shows it at once.
ring = cfg.ring.map { if (it is SlotId.Shortcut && it.shortcutId == id) null else it },
shortcuts = cfg.shortcuts.filter { it.id != id },
).toJson(),
)
}
editingShortcut?.let { draft ->
ShortcutEditor(
draft = draft,
onSave = { save(it); editingShortcut = null },
onDelete = { remove(draft.id); editingShortcut = null },
onBack = { editingShortcut = null },
)
return
}
BackHandler(onBack = onBack)
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack, modifier = Modifier.padding(end = 4.dp)) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text("Quick actions", style = MaterialTheme.typography.headlineMedium)
}
// The ring in a card like every other field, but without the group's 16 dp inset: the
// inset left the stage narrower than the ring. Its caption sits under the card the way a
// group's footer does.
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
RingStage(cfg, RingEditing(pick = { picking = it }, swap = ::swap))
}
Text(
"Tap a button to change it, drag one onto another to swap." +
if (overridden) " This profile has its own quick actions; the default ring no longer reaches it." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp),
)
}
// The virtual controller's preset and look (§4.3), written to the blob's `pad` through the
// same path the ring uses.
SettingsGroup(
"Virtual controller",
footer = "Shown from the ring's Virtual controller button. A finger on one of its controls drives the game; a finger anywhere else drives the touch mode.",
) {
SettingDropdown(
label = "Layout",
options = listOf("full" to "Full", "sticks" to "Sticks and shoulders", "dpad" to "D-pad and face buttons"),
selected = cfg.pad.layout,
caption = "Which controls the controller shows; fewer controls leave more of the picture uncovered.",
) { onChange(cfg.copy(pad = cfg.pad.copy(layout = it)).toJson()) }
PadSlider(
"Opacity", cfg.pad.opacity, PAD_OPACITY_MIN..1f,
"How strongly the controls draw over the picture; higher hides more of the game.",
) { onChange(cfg.copy(pad = cfg.pad.copy(opacity = it)).toJson()) }
PadSlider(
"Scale", cfg.pad.scale, PAD_SCALE_MIN..PAD_SCALE_MAX,
"How large the controls are; larger ones cover more of the picture.",
) { onChange(cfg.copy(pad = cfg.pad.copy(scale = it)).toJson()) }
}
SettingsGroup("Shortcuts", footer = "A chord the ring sends to the host. A new one takes the first empty slot.") {
cfg.shortcuts.forEach { sc ->
Row(
modifier = Modifier.fillMaxWidth().clickable {
editingShortcut = ShortcutDraft(sc.id, sc.label, sc.keys, isNew = false)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
KeycapDisc(sc.keys, 40.dp)
Column(Modifier.weight(1f)) {
Text(sc.label.ifEmpty { chordChip(sc.keys) }, style = MaterialTheme.typography.bodyLarge)
if (sc.label.isNotEmpty()) {
Text(
chordChip(sc.keys),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = {
val next = (cfg.shortcuts.mapNotNull { it.id.drop(1).toIntOrNull() }.maxOrNull() ?: 0) + 1
editingShortcut = ShortcutDraft("s$next", "", emptyList(), isNew = true)
}) { Text("Add shortcut") }
}
SettingsGroup(footer = "Restores the platform ring and removes the shortcuts.") {
TextButton(onClick = onReset) {
Text("Reset to default", color = MaterialTheme.colorScheme.error)
}
}
}
picking?.let { k ->
SlotPicker(
groups = slotGroups(cfg),
current = cfg.ring[k]?.id ?: "",
onPick = { set(k, it); picking = null },
onDismiss = { picking = null },
)
}
}
/**
* The live ring over a backdrop that runs the stream's own twist: [streamTouchInput] with a `0`
* handle sends nothing to a host and still arms, commits and cancels the dial. Whatever closes
* the ring a twist wound back, a preview row that ends the stream it springs back open, so
* there is never a dead editor with nothing to tap.
*/
@Composable
private fun RingStage(cfg: OverlayConfig, editing: RingEditing) {
val ring = remember { RingState() }
val haptics = rememberConsoleHaptics()
var size by remember { mutableStateOf(IntSize.Zero) }
// Read the size INSIDE the effects: a centre computed at composition was one step behind
// the size state on the first open and put the ring at (0, 0), clamped to the margin.
fun centre() = Offset(size.width / 2f, size.height / 2f)
LaunchedEffect(size) {
if (size != IntSize.Zero && !ring.committed) ring.openAt(centre())
}
LaunchedEffect(ring.committed, ring.progress) {
if (!ring.committed && ring.progress == 0f && size != IntSize.Zero) {
delay(250)
if (!ring.committed && ring.progress == 0f) ring.openAt(centre())
}
}
// No fill of its own: the card it sits in is the field. The twist surface is a SIBLING
// under the ring, as the stream's gesture layer is — on a parent it took the moves before
// the discs' drag detectors saw them, so a drag never started.
Box(
Modifier
.fillMaxWidth()
.height(380.dp)
.onSizeChanged { size = it },
) {
Box(
Modifier
.matchParentSize()
.pointerInput(Unit) {
streamTouchInput(
handle = 0L, stylus = null, videoAspect = 1f, trackpad = true, invertScroll = false,
onCycleStats = {}, onKeyboard = {},
) { ev ->
when (ev) {
is DialEvent.Turn -> if (ring.turn(ev.progress, ev.clockwise, ev.x, ev.y)) haptics.tick()
DialEvent.Commit -> { ring.commit(); haptics.confirm() }
DialEvent.Cancel -> ring.cancel()
}
}
},
)
RingOverlay(
state = ring,
cfg = cfg,
actions = previewActions,
containerSize = size,
haptics = haptics,
editing = editing,
)
}
}
/** A slider that names its value as a percentage and writes it when the finger lifts, not per frame. */
@Composable
private fun PadSlider(
label: String,
value: Float,
range: ClosedFloatingPointRange<Float>,
caption: String,
onCommit: (Float) -> Unit,
) {
var v by remember(value) { mutableFloatStateOf(value) }
Column {
Text("$label · ${(v * 100).roundToInt()}%", style = MaterialTheme.typography.bodyLarge)
Slider(value = v, onValueChange = { v = it }, valueRange = range, onValueChangeFinished = { onCommit(v) })
Text(caption, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
/** The ring's commands with nothing behind them: the editor shows, it never fires (§3.3). */
private val previewActions = RingActions(
endStream = {}, disconnectLinger = {},
touchMode = { TouchMode.TRACKPAD }, cycleTouchMode = {},
keyboardGranted = { true }, keyboard = {},
textSupported = true, sendText = {},
stats = { StatsVerbosity.COMPACT }, cycleStats = {},
micAvailable = { true }, micMuted = { false }, toggleMic = {},
// The three power actions as a host that offers all three would show them; a dimmed
// "does not offer it" would lie about the slot.
hostActions = {
listOf(
HostActions.Action("power.sleep", "Sleep host", danger = false, available = true, unavailableReason = ""),
HostActions.Action("power.reboot", "Restart host", danger = true, available = true, unavailableReason = ""),
HostActions.Action("power.shutdown", "Shut down host", danger = true, available = true, unavailableReason = ""),
)
},
invokeHost = {}, sendShortcut = {},
padAvailable = { true }, padShown = { false }, togglePad = {},
currentMode = { intArrayOf(1920, 1080, 60) }, requestMode = { _, _, _ -> },
)
private data class SlotOption(val id: String, val label: String, val note: String? = null)
private data class SlotGroup(val title: String, val options: List<SlotOption>)
/** The catalogue by group (§3.3) with each entry's availability note; the profile's own
* shortcuts and the empty slot are appended per config. */
private fun slotGroups(cfg: OverlayConfig): List<SlotGroup> {
val g = mutableListOf(
SlotGroup("Session", listOf(
SlotOption("end_stream", "End stream"),
SlotOption("disconnect_linger", "Disconnect, keep the game running"),
)),
SlotGroup("Input", listOf(
SlotOption("touch_mode", "Touch mode"),
SlotOption("keyboard", "Keyboard"),
SlotOption("pad", "Virtual controller", "Shows or hides the on-screen controller"),
SlotOption("send_text", "Send text"),
)),
SlotGroup("View", listOf(SlotOption("stats", "Statistics"))),
SlotGroup("Audio", listOf(SlotOption("mic", "Microphone"))),
SlotGroup("Host", listOf(
SlotOption("host:power.sleep", "Sleep host", "Only where the host offers it"),
SlotOption("host:power.reboot", "Restart host", "Only where the host offers it"),
SlotOption("host:power.shutdown", "Shut down host", "Only where the host offers it"),
)),
)
if (cfg.shortcuts.isNotEmpty()) {
g += SlotGroup("Shortcuts", cfg.shortcuts.map {
SlotOption("shortcut:${it.id}", it.label.ifEmpty { chordChip(it.keys) }, if (it.label.isEmpty()) null else chordChip(it.keys))
})
}
g += SlotGroup("Empty", listOf(SlotOption("", "Empty slot")))
return g
}
/** The catalogue by group; the current pick is marked. */
@Composable
private fun SlotPicker(groups: List<SlotGroup>, current: String, onPick: (String) -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Slot action") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
groups.forEach { g ->
Text(
g.title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
g.options.forEach { o ->
Row(
Modifier.fillMaxWidth().clickable { onPick(o.id) }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(o.label, style = MaterialTheme.typography.bodyLarge)
o.note?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (o.id == current) Text("", color = MaterialTheme.colorScheme.primary)
}
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/** A shortcut on the editing screen, new or existing. */
private data class ShortcutDraft(val id: String, val label: String, val keys: List<String>, val isNew: Boolean)
private val MODIFIER_KEYS = listOf("ctrl", "alt", "shift", "win")
/** The keys a chord can end on, grouped the way a keyboard groups them — every name [keyVk] knows. */
private val KEY_GROUPS: List<Pair<String, List<String>>> = listOf(
"Function" to (listOf("escape") + (1..12).map { "f$it" }),
"Letters" to "qwertyuiopasdfghjklzxcvbnm".map { it.toString() },
"Numbers" to ((1..9).map { it.toString() } + "0"),
"Editing" to listOf("tab", "space", "enter", "backspace", "delete", "insert"),
"Navigation" to listOf("home", "end", "pageup", "pagedown", "up", "down", "left", "right"),
"Other" to listOf("printscreen", "pause", "capslock"),
)
/**
* One shortcut: a name, the modifiers held as chips, the key it ends on picked from a keyboard,
* and the disc as the ring will draw it.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ShortcutEditor(
draft: ShortcutDraft,
onSave: (ShortcutDraft) -> Unit,
onDelete: () -> Unit,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
var label by remember { mutableStateOf(draft.label) }
var mods by remember { mutableStateOf(draft.keys.filter { it in MODIFIER_KEYS }) }
var key by remember { mutableStateOf(draft.keys.firstOrNull { it !in MODIFIER_KEYS }) }
// Modifiers first in keyboard order, then the key — the order the chord is sent.
val keys = MODIFIER_KEYS.filter { it in mods } + listOfNotNull(key)
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack, modifier = Modifier.padding(end = 4.dp)) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(if (draft.isNew) "New shortcut" else "Shortcut", style = MaterialTheme.typography.headlineMedium)
}
SettingsGroup {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
KeycapDisc(keys)
Column {
Text(
label.ifEmpty { if (key == null) "Pick a key" else chordChip(keys) },
style = MaterialTheme.typography.bodyLarge,
)
Text(
if (key == null) "The disc as the ring will draw it" else chordChip(keys),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = if (key == null) null else FontFamily.Monospace,
)
}
}
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Name (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
SettingsGroup("Hold") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MODIFIER_KEYS.forEach { m ->
FilterChip(
selected = m in mods,
onClick = { mods = if (m in mods) mods - m else mods + m },
label = { Text(keyLegend(m)) },
)
}
}
}
KEY_GROUPS.forEach { (title, group) ->
SettingsGroup(title) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
group.forEach { k ->
FilterChip(
selected = key == k,
onClick = { key = k },
// A fixed-width chip lays its label out from the start; centre it.
label = {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(keyLegend(k), maxLines = 1, softWrap = false)
}
},
modifier = Modifier.width(if (keyLegend(k).length > 2) 88.dp else 48.dp),
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { onSave(draft.copy(label = label, keys = keys)) }, enabled = key != null) {
Text(if (draft.isNew) "Add" else "Save")
}
TextButton(onClick = onBack) { Text("Cancel") }
Spacer(Modifier.weight(1f))
if (!draft.isNew) {
TextButton(onClick = onDelete) { Text("Remove", color = MaterialTheme.colorScheme.error) }
}
}
}
}
@@ -0,0 +1,800 @@
package io.unom.punktfunk
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Insights
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.Mouse
import androidx.compose.material.icons.filled.PanTool
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.RingNav
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.roundToInt
import kotlin.math.sin
/*
* The quick-action ring (design/touch-client-overlay.md §2): six round buttons on a circle under
* the fingers plus a centre "More" that opens the sheet the complete catalogue with values.
* The two-finger twist drives the opening frame by frame ([RingState.turn]); the back gesture
* opens it at the screen centre. Closed, it leaves the composition entirely (tenet 1).
*/
private val RING_RADIUS = 120.dp
private val SLOT_D = 56.dp
private val CENTRE_D = 64.dp
private const val IDLE_CLOSE_MS = 8_000L
private const val ARM_MS = 2_000L
private const val HINT_MS = 2_000L
/** Button k lags the previous one by this much of the twist, so the ring visibly unwinds. */
private const val SLOT_LAG = 0.06f
/** The ring's open/closed state and what it is showing. One per stream; the overlay reads it. */
class RingState {
/** 0 closed … 1 open — driven by the twist until [committed]. */
var progress by mutableStateOf(0f)
var committed by mutableStateOf(false)
var clockwise by mutableStateOf(true)
/** Container px; the ring is centred here, clamped so it stays on screen. */
var centre by mutableStateOf(Offset.Zero)
var sheet by mutableStateOf(false)
/** A destructive slot awaiting its second press (the slot id). */
var armed by mutableStateOf<String?>(null)
/** The label under the ring: a slot's name, why it is unavailable, or "press again". */
var hint by mutableStateOf<String?>(null)
var lastTouch by mutableLongStateOf(0L)
private var twistArmed = false
/** The pad's highlight: a slot 05, or 6 for the centre (the initial one `Select+A`
* then A opens the sheet in two presses). `null` until a pad moves it. */
var highlight by mutableStateOf<Int?>(null)
/** The sheet row the pad is on. */
var sheetCursor by mutableIntStateOf(0)
/** The mode at first open — the Resolution row's "Native". */
var nativeMode: IntArray? = null
/** A pad press awaiting the overlay ([RingOverlay] consumes it); [navSeq] makes each one an event. */
var pendingNav by mutableStateOf<RingNav?>(null)
var navSeq by mutableIntStateOf(0)
/** Open/close edges, for the shell: the pad router masks itself while the ring is up. */
var onOpenChange: ((Boolean) -> Unit)? = null
fun nav(n: RingNav) {
pendingNav = n
navSeq++
}
val visible: Boolean get() = committed || progress > 0f
/** Returns true on the first turn of a twist — the moment the dial arms, worth one tick. */
fun turn(p: Float, cw: Boolean, x: Float, y: Float): Boolean {
if (committed) return false
progress = p
clockwise = cw
centre = Offset(x, y)
val first = !twistArmed
twistArmed = true
return first
}
fun commit() {
val was = committed
committed = true
progress = 1f
touch()
if (!was) onOpenChange?.invoke(true)
}
/** The twist lifted short of commit, or wound back after one: the ring winds back in. */
fun cancel() = close()
fun openAt(c: Offset) {
centre = c
commit()
}
fun close() {
val was = committed
committed = false
progress = 0f
sheet = false
armed = null
hint = null
highlight = null
twistArmed = false
if (was) onOpenChange?.invoke(false)
}
fun touch() {
lastTouch = System.currentTimeMillis()
}
}
/** What the ring can do this session — the shell's live state and commands behind each slot. */
class RingActions(
val endStream: () -> Unit,
val disconnectLinger: () -> Unit,
val touchMode: () -> TouchMode,
val cycleTouchMode: () -> Unit,
val keyboardGranted: () -> Boolean,
val keyboard: () -> Unit,
val textSupported: Boolean,
val sendText: (String) -> Unit,
val stats: () -> StatsVerbosity,
val cycleStats: () -> Unit,
val micAvailable: () -> Boolean,
val micMuted: () -> Boolean,
val toggleMic: () -> Unit,
val hostActions: () -> List<HostActions.Action>,
val invokeHost: (HostActions.Action) -> Unit,
val sendShortcut: (List<String>) -> Unit,
/** The virtual controller (§4): whether its input can reach the host, whether it is up, and the toggle. */
val padAvailable: () -> Boolean,
val padShown: () -> Boolean,
val togglePad: () -> Unit,
/** `[w, h, hz]` as last requested (Android has no live read-back of the negotiated mode). */
val currentMode: () -> IntArray,
val requestMode: (Int, Int, Int) -> Unit,
)
/**
* The editor's hooks (design §3.3): a tap on a slot picks its action instead of firing it, and a
* disc dragged onto another slot swaps the two. Null in-stream.
*/
class RingEditing(val pick: (Int) -> Unit, val swap: (Int, Int) -> Unit)
/** One button as the ring draws it: glyph or keycap chip, its state, and why it is dimmed. */
private data class SlotSpec(
val id: String,
val label: String,
val icon: ImageVector? = null,
val chip: String? = null,
val enabled: Boolean = true,
val reason: String = "",
/** Destructive: two presses. */
val armed: Boolean = false,
/** A toggle leaves the ring open so the new state is visible (D6). */
val toggle: Boolean = false,
val state: String = "",
)
private fun spec(slot: SlotId, cfg: OverlayConfig, a: RingActions): SlotSpec = when (slot) {
SlotId.EndStream -> SlotSpec("end_stream", "End stream", Icons.Filled.Close, armed = true)
SlotId.DisconnectLinger ->
SlotSpec("disconnect_linger", "Disconnect, keep the game running", Icons.Filled.Logout)
SlotId.TouchMode -> {
val m = a.touchMode()
SlotSpec(
"touch_mode", "Touch mode",
when (m) {
TouchMode.TRACKPAD -> Icons.Filled.TouchApp
TouchMode.POINTER -> Icons.Filled.Mouse
TouchMode.TOUCH -> Icons.Filled.PanTool
},
toggle = true, state = m.name.lowercase().replaceFirstChar { it.uppercase() },
)
}
SlotId.Keyboard -> SlotSpec(
"keyboard", "Keyboard", Icons.Filled.Keyboard,
enabled = a.keyboardGranted(), reason = "Keyboard input is not granted for this session",
)
SlotId.Stats -> SlotSpec(
"stats", "Statistics", Icons.Filled.Insights, toggle = true, state = a.stats().label,
)
SlotId.Mic -> SlotSpec(
"mic", "Microphone", if (a.micMuted()) Icons.Filled.MicOff else Icons.Filled.Mic,
enabled = a.micAvailable(), reason = "No microphone is running this session",
toggle = true, state = if (a.micMuted()) "Muted" else "On",
)
SlotId.Pad -> SlotSpec(
"pad", "Virtual controller", Icons.Filled.SportsEsports,
enabled = a.padAvailable(), reason = "Controller input is not forwarded this session",
toggle = true, state = if (a.padShown()) "On" else "Off",
)
SlotId.SendText -> SlotSpec(
"send_text", "Send text", Icons.Filled.TextFields,
enabled = a.textSupported && a.keyboardGranted(),
reason = "This host does not take typed text",
)
is SlotId.Host -> {
val act = a.hostActions().firstOrNull { it.id == slot.actionId }
// Three power actions, three glyphs — the same icon on all three made them one button.
val icon = when (slot.actionId) {
"power.sleep" -> Icons.Filled.Bedtime
"power.reboot" -> Icons.Filled.RestartAlt
else -> Icons.Filled.PowerSettingsNew
}
SlotSpec(
"host:${slot.actionId}", act?.label ?: slot.actionId, icon,
enabled = act?.available == true,
reason = act?.unavailableReason?.ifEmpty { null } ?: "This host does not offer it",
armed = act?.danger ?: true,
)
}
is SlotId.Shortcut -> {
val s = cfg.shortcut(slot.shortcutId)
SlotSpec(
"shortcut:${slot.shortcutId}", s?.label?.ifEmpty { null } ?: chordChip(s?.keys.orEmpty()),
chip = chordChip(s?.keys.orEmpty()),
enabled = a.keyboardGranted() && s?.keys?.all { keyVk(it) != null } == true,
reason = if (a.keyboardGranted()) "A key in this chord is unknown" else "Keyboard input is not granted for this session",
)
}
}
/**
* The ring and its sheet. Sits above the gesture layer, so its buttons take the finger first;
* a tap on the scrim outside closes it. Composed only while [RingState.visible].
*/
@Composable
fun RingOverlay(
state: RingState,
cfg: OverlayConfig,
actions: RingActions,
containerSize: IntSize,
haptics: ConsoleHaptics,
modifier: Modifier = Modifier,
/** Set by the settings editor; null in-stream. */
editing: RingEditing? = null,
) {
if (!state.visible) return
val density = LocalDensity.current
val radiusPx = with(density) { RING_RADIUS.toPx() }
val slotPx = with(density) { SLOT_D.toPx() }
val marginPx = radiusPx + slotPx / 2 + with(density) { 16.dp.toPx() }
// Clamped into the container so the whole ring is always on screen; a container narrower
// than two margins (the editor's stage) centres it instead of pinning it to one side.
val cx = if (containerSize.width < 2 * marginPx) containerSize.width / 2f
else state.centre.x.coerceIn(marginPx, containerSize.width - marginPx)
val cy = if (containerSize.height < 2 * marginPx) containerSize.height / 2f
else state.centre.y.coerceIn(marginPx, containerSize.height - marginPx)
// The twist drives the opening frame by frame; the commit settles with a spring; a close
// shrinks it into the centre.
val shown = remember { Animatable(0f) }
LaunchedEffect(state.progress, state.committed) {
when {
state.committed -> shown.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMediumLow))
state.progress <= 0f -> shown.animateTo(0f, tween(200))
else -> shown.snapTo(state.progress)
}
}
// Idle: the exit disc's 8 s rule, for the same latency reason — unless the sheet is up.
LaunchedEffect(state.committed, state.lastTouch, state.sheet) {
if (state.committed && !state.sheet && editing == null) {
delay(IDLE_CLOSE_MS)
state.close()
}
}
// An armed slot and a hint both time out.
LaunchedEffect(state.armed, state.hint, state.lastTouch) {
if (state.armed != null || state.hint != null) {
delay(ARM_MS.coerceAtLeast(HINT_MS))
state.armed = null
state.hint = null
}
}
var textDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { if (state.nativeMode == null) state.nativeMode = actions.currentMode() }
val rows = if (state.sheet) sheetRows(state, cfg, actions, haptics) { textDialog = true } else emptyList()
// The haptic vocabulary: a tap per press, a firm "no" on a dimmed button, a warning when a
// destructive slot arms, and the confirm on the commit (StreamScreen fires that one).
fun fire(s: SlotSpec, slot: SlotId) {
state.touch()
if (!s.enabled) {
haptics.boundary()
state.armed = null
state.hint = s.reason
return
}
if (s.armed && state.armed != s.id) {
haptics.boundary()
state.armed = s.id
state.hint = "${s.label}? Tap again"
return
}
haptics.tick()
state.armed = null
state.hint = null
when (slot) {
SlotId.EndStream -> { state.close(); actions.endStream() }
SlotId.DisconnectLinger -> { state.close(); actions.disconnectLinger() }
SlotId.TouchMode -> actions.cycleTouchMode()
SlotId.Keyboard -> { state.close(); actions.keyboard() }
SlotId.Stats -> actions.cycleStats()
SlotId.Mic -> actions.toggleMic()
SlotId.Pad -> actions.togglePad()
SlotId.SendText -> textDialog = true
is SlotId.Host -> {
actions.hostActions().firstOrNull { it.id == slot.actionId }?.let { state.close(); actions.invokeHost(it) }
}
is SlotId.Shortcut -> {
cfg.shortcut(slot.shortcutId)?.let { state.close(); actions.sendShortcut(it.keys) }
}
}
if (s.toggle) state.hint = spec(slot, cfg, actions).let { "${it.label}: ${it.state}" }
}
// The pad (design §2.6): Right steps the highlight clockwise, Left anticlockwise, Up jumps
// to 12 o'clock, Down to 6, Y returns it to the centre; A fires the highlight (the centre
// opens the sheet), B closes. In the sheet, Up/Down walk the rows, Left/Right adjust one.
LaunchedEffect(state.navSeq) {
val n = state.pendingNav ?: return@LaunchedEffect
state.pendingNav = null
state.touch()
if (state.sheet) {
when (n) {
RingNav.UP -> { state.sheetCursor = (state.sheetCursor - 1).coerceAtLeast(0); haptics.tick() }
RingNav.DOWN -> { state.sheetCursor = (state.sheetCursor + 1).coerceAtMost(rows.lastIndex.coerceAtLeast(0)); haptics.tick() }
RingNav.LEFT -> rows.getOrNull(state.sheetCursor)?.onAdjust?.let { it(-1); haptics.tick() } ?: haptics.boundary()
RingNav.RIGHT -> rows.getOrNull(state.sheetCursor)?.onAdjust?.let { it(1); haptics.tick() } ?: haptics.boundary()
RingNav.CONFIRM -> rows.getOrNull(state.sheetCursor)?.let { if (it.enabled) haptics.tick() else haptics.boundary(); it.onTap() }
RingNav.BACK -> { state.sheet = false; haptics.tick() }
RingNav.CENTRE -> {}
}
return@LaunchedEffect
}
val h = state.highlight ?: 6
when (n) {
RingNav.RIGHT -> { state.highlight = if (h >= 6) 0 else (h + 1) % 6; haptics.tick() }
RingNav.LEFT -> { state.highlight = if (h >= 6) 5 else (h + 5) % 6; haptics.tick() }
RingNav.UP -> { state.highlight = 0; haptics.tick() }
RingNav.DOWN -> { state.highlight = 3; haptics.tick() }
RingNav.CENTRE -> { state.highlight = 6; haptics.tick() }
RingNav.CONFIRM -> if (h >= 6) { haptics.tick(); state.sheetCursor = 0; state.sheet = true } else {
cfg.ring[h]?.let { fire(spec(it, cfg, actions), it) } ?: haptics.boundary()
}
RingNav.BACK -> state.close()
}
}
Box(
modifier
.fillMaxSize()
// The scrim: a tap outside the ring closes it, and nothing reaches the stream while
// it is open. No backdrop blur — a blur over a video surface is a full-screen pass.
// The editor has no stream under it and draws no scrim.
.background(Color.Black.copy(alpha = if (editing == null) 0.18f * shown.value else 0f))
// In the editor the backdrop under the ring owns the twist; the scrim takes nothing.
.then(
if (editing == null) {
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { if (state.sheet) state.sheet = false else state.close() }
} else {
Modifier
},
),
) {
val slotHalf = slotPx / 2
// The disc under an editing drag and how far it has been carried.
var drag by remember { mutableStateOf<Pair<Int, Offset>?>(null) }
// Editing: which slot each disc is drawn in. Identity except while a swap plays: the
// two discs travel to each other's slots on a spring, then the blob is written and
// this snaps back to identity with the contents swapped — nothing visible moves then.
var order by remember { mutableStateOf((0 until 6).toList()) }
var swapping by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
cfg.ring.forEachIndexed { k, slot ->
val q = ((shown.value - k * SLOT_LAG) / (1f - 5 * SLOT_LAG)).coerceIn(0f, 1f)
if (q <= 0f) return@forEachIndexed
// Slot k sits at 12, 2, 4… o'clock; it travels out along a short spiral that turns
// the way the hand turns. `order` redirects a disc to another slot while a swap plays.
val turn = if (state.clockwise) -40f else 40f
val deg = -90f + 60f * order[k] + (1f - q) * turn
val rad = Math.toRadians(deg.toDouble())
val carried = drag?.takeIf { it.first == k }?.second ?: Offset.Zero
val targetX = cx + radiusPx * q * cos(rad).toFloat() - slotHalf + carried.x
val targetY = cy + radiusPx * q * sin(rad).toFloat() - slotHalf + carried.y
// Only a swap animates the position; the twist and a drag follow the finger.
val swapSpring = if (swapping) spring<Float>(dampingRatio = 0.82f, stiffness = Spring.StiffnessMedium) else snap()
val x by animateFloatAsState(targetX, swapSpring, label = "slotX")
val y by animateFloatAsState(targetY, swapSpring, label = "slotY")
val s = slot?.let { spec(it, cfg, actions) }
// Editing: a disc dragged onto another slot swaps the two (§3.3); released near the
// centre or over its own slot, nothing changes. The drag consumes past touch slop,
// which is what keeps the tap from also firing.
val dragModifier = if (editing != null) {
Modifier.pointerInput(k, editing) {
detectDragGestures(
onDragStart = { drag = k to Offset.Zero },
onDragEnd = {
val d = drag
drag = null
if (d == null || d.first != k) return@detectDragGestures
val home = Math.toRadians(-90.0 + 60.0 * k)
val px = radiusPx * cos(home).toFloat() + d.second.x
val py = radiusPx * sin(home).toFloat() + d.second.y
if (hypot(px, py) <= radiusPx / 2) return@detectDragGestures
val angle = Math.toDegrees(atan2(py, px).toDouble()) + 90.0
val target = (((angle / 60.0).roundToInt() % 6) + 6) % 6
if (target == k || swapping) return@detectDragGestures
// The two discs travel to each other's slots; once they land the
// blob is written and `order` resets with the spring off, so the
// swap of contents draws nothing.
swapping = true
order = order.toMutableList().also { o ->
val t = o[k]
o[k] = o[target]
o[target] = t
}
// Past the spring's settle: a reset while the discs still move
// jumped them the last few pixels as the contents swapped.
scope.launch {
delay(650)
swapping = false
order = (0 until 6).toList()
editing.swap(k, target)
}
},
onDragCancel = { drag = null },
) { change, delta ->
change.consume()
drag = (drag?.takeIf { it.first == k } ?: (k to Offset.Zero)).let { it.first to it.second + delta }
}
}
} else {
Modifier
}
RingButton(
spec = s,
size = SLOT_D,
scale = 0.6f + 0.4f * q,
alpha = q,
armed = s != null && state.armed == s.id,
highlighted = state.highlight == k,
modifier = Modifier.offset { IntOffset(x.roundToInt(), y.roundToInt()) }.then(dragModifier),
editable = editing != null,
onTap = {
if (editing != null) editing.pick(k) else if (slot != null && s != null) fire(s, slot)
},
)
}
// The centre arrives last and opens the sheet.
val cq = ((shown.value - 6 * SLOT_LAG) / (1f - 6 * SLOT_LAG)).coerceIn(0f, 1f)
if (cq > 0f) {
val centreHalf = with(density) { CENTRE_D.toPx() } / 2
// In the editor the centre is not editable, so it sits dimmed and inert.
RingButton(
spec = SlotSpec("more", "More", Icons.Filled.MoreHoriz),
size = CENTRE_D,
scale = 0.6f + 0.4f * cq,
alpha = cq * (if (editing == null) 1f else 0.45f),
armed = false,
highlighted = state.highlight == 6,
modifier = Modifier.offset { IntOffset((cx - centreHalf).roundToInt(), (cy - centreHalf).roundToInt()) },
inert = editing != null,
onTap = { state.touch(); haptics.tick(); state.sheet = true },
)
}
// The label under the ring: a hint, else the highlighted slot's name.
val label = state.hint ?: state.highlight?.let { h ->
if (h == 6) "More" else cfg.ring[h]?.let { spec(it, cfg, actions).label }
}
label?.let { hint ->
val labelY = cy + radiusPx + slotPx
Text(
hint,
modifier = Modifier
.align(Alignment.TopCenter)
.offset { IntOffset(0, labelY.roundToInt()) }
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
if (state.sheet) {
RingSheet(state, rows, haptics, Modifier.align(Alignment.BottomCenter))
}
}
if (textDialog) {
var text by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { textDialog = false },
title = { Text("Send text") },
text = { OutlinedTextField(text, { text = it }, singleLine = true, modifier = Modifier.fillMaxWidth()) },
confirmButton = {
TextButton(onClick = { textDialog = false; state.close(); actions.sendText(text) }) { Text("Send") }
},
dismissButton = { TextButton(onClick = { textDialog = false }) { Text("Cancel") } },
)
}
}
/** One round translucent button — the in-stream pill family's surface, in a circle. */
@Composable
private fun RingButton(
spec: SlotSpec?,
size: androidx.compose.ui.unit.Dp,
scale: Float,
alpha: Float,
armed: Boolean,
modifier: Modifier,
highlighted: Boolean = false,
/** The editor: an empty slot is a pick target, not inert. */
editable: Boolean = false,
/** The editor's centre: drawn, never pressed. */
inert: Boolean = false,
onTap: () -> Unit,
) {
val tint = when {
armed -> Color(0xFFFF5A5A)
spec == null || !spec.enabled -> Color.White.copy(alpha = 0.35f)
else -> Color.White
}
Box(
modifier
.size(size)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(CircleShape)
.background(Color.Black.copy(alpha = if (armed) 0.75f else 0.55f))
.border(
if (highlighted) 2.dp else 1.dp,
Color.White.copy(alpha = if (highlighted) 0.8f else if (armed) 0.6f else 0.18f),
CircleShape,
)
.clickable(enabled = !inert && (spec != null || editable), onClick = onTap)
.semantics {
contentDescription = spec?.label ?: "Empty slot"
stateDescription = when {
armed -> "armed — press again"
spec?.enabled == false -> spec.reason
spec?.state?.isNotEmpty() == true -> spec.state
else -> ""
}
},
contentAlignment = Alignment.Center,
) {
when {
spec?.chip != null -> ChordKeycap(spec.chip, tint, size)
spec?.icon != null -> Icon(spec.icon, contentDescription = null, tint = tint, modifier = Modifier.size(size / 2))
}
}
}
/**
* A chord as a stacked keycap: modifiers small on top, the key large under them one legend
* line ran past the disc's edge. [chip] is `chordChip`'s text. The editor previews a chord with
* this same composable.
*/
@Composable
internal fun ChordKeycap(chip: String, tint: Color, size: androidx.compose.ui.unit.Dp) {
val parts = chip.split("+")
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(size - 12.dp)) {
if (parts.size > 1) {
Text(
parts.dropLast(1).joinToString("+"),
color = tint, fontSize = 8.sp, fontWeight = FontWeight.SemiBold,
maxLines = 1, softWrap = false,
)
}
Text(
parts.last(),
color = tint, fontSize = if (parts.last().length > 3) 10.sp else 13.sp,
fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false,
)
}
}
/** A chord on a disc the size the ring draws it, for lists and the editing screen. */
@Composable
internal fun KeycapDisc(keys: List<String>, size: androidx.compose.ui.unit.Dp = SLOT_D) {
Box(
Modifier
.size(size)
.clip(CircleShape)
.background(Color(0xFF383838))
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape),
contentAlignment = Alignment.Center,
) {
if (keys.isEmpty()) {
Text("?", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
} else {
ChordKeycap(chordChip(keys), Color.White, size)
}
}
}
/** One row of the sheet as data, so a finger and a pad drive the same list. */
private data class SheetRowSpec(
val header: String? = null,
val label: String,
val value: String = "",
val enabled: Boolean = true,
/** Left/Right on a pad: cycle a value (the resolution rows); a tap cycles forward. */
val onAdjust: ((Int) -> Unit)? = null,
val onTap: () -> Unit,
)
private val RES_PRESETS = listOf("1440p" to (2560 to 1440), "1080p" to (1920 to 1080), "720p" to (1280 to 720))
private val HZ_PRESETS = listOf(120, 60)
/** Depth two: the complete catalogue in a fixed order (D2). */
private fun sheetRows(
state: RingState,
cfg: OverlayConfig,
actions: RingActions,
haptics: ConsoleHaptics,
requestText: () -> Unit,
): List<SheetRowSpec> {
val rows = mutableListOf<SheetRowSpec>()
val mode = actions.currentMode()
val native = state.nativeMode ?: mode
val (w, h, hz) = Triple(mode.getOrElse(0) { 0 }, mode.getOrElse(1) { 0 }, mode.getOrElse(2) { 60 })
val (nw, nh, nhz) = Triple(native.getOrElse(0) { 0 }, native.getOrElse(1) { 0 }, native.getOrElse(2) { 60 })
val resLabel = if (w == nw && h == nh) "Native ($w×$h)" else RES_PRESETS.firstOrNull { it.second == (w to h) }?.first ?: "$w×$h"
fun adjustRes(dir: Int) {
val options = listOf(nw to nh) + RES_PRESETS.map { it.second }
val i = options.indexOf(w to h).coerceAtLeast(0)
val n = options.size
val next = options[((i + dir) % n + n) % n]
actions.requestMode(next.first, next.second, hz)
}
fun adjustHz(dir: Int) {
val options = (listOf(nhz) + HZ_PRESETS).distinct()
val i = options.indexOf(hz).coerceAtLeast(0)
val n = options.size
actions.requestMode(w, h, options[((i + dir) % n + n) % n])
}
rows += SheetRowSpec("Session", "End stream", if (state.armed == "end_stream") "tap again" else "") {
if (state.armed == "end_stream") { state.close(); actions.endStream() } else { haptics.boundary(); state.armed = "end_stream" }
}
rows += SheetRowSpec(null, "Disconnect, keep the game running") { state.close(); actions.disconnectLinger() }
rows += SheetRowSpec("Resolution", "Resolution", resLabel, onAdjust = ::adjustRes) { adjustRes(1) }
rows += SheetRowSpec(null, "Refresh", "$hz Hz", onAdjust = ::adjustHz) { adjustHz(1) }
val tm = spec(SlotId.TouchMode, cfg, actions)
rows += SheetRowSpec("Input", tm.label, tm.state) { actions.cycleTouchMode() }
val kb = spec(SlotId.Keyboard, cfg, actions)
rows += SheetRowSpec(null, kb.label, if (kb.enabled) "" else kb.reason, kb.enabled) { if (kb.enabled) { state.close(); actions.keyboard() } }
val st = spec(SlotId.SendText, cfg, actions)
rows += SheetRowSpec(null, st.label, if (st.enabled) "" else st.reason, st.enabled) { if (st.enabled) requestText() }
val pad = spec(SlotId.Pad, cfg, actions)
rows += SheetRowSpec(null, pad.label, if (pad.enabled) pad.state else pad.reason, pad.enabled) { if (pad.enabled) actions.togglePad() }
rows += SheetRowSpec("View", "Statistics", actions.stats().label) { actions.cycleStats() }
val mic = spec(SlotId.Mic, cfg, actions)
rows += SheetRowSpec("Audio", mic.label, if (mic.enabled) mic.state else mic.reason, mic.enabled) { if (mic.enabled) actions.toggleMic() }
actions.hostActions().forEachIndexed { i, act ->
val id = "host:${act.id}"
rows += SheetRowSpec(
if (i == 0) "Host" else null, act.label,
when {
!act.available -> act.unavailableReason
state.armed == id -> "tap again"
else -> ""
},
act.available,
) {
if (!act.available) return@SheetRowSpec
if (act.danger && state.armed != id) { haptics.boundary(); state.armed = id } else { state.close(); actions.invokeHost(act) }
}
}
cfg.shortcuts.forEachIndexed { i, s ->
val ok = actions.keyboardGranted() && s.keys.all { keyVk(it) != null }
rows += SheetRowSpec(if (i == 0) "Shortcuts" else null, s.label.ifEmpty { chordChip(s.keys) }, chordChip(s.keys), ok) {
if (ok) { state.close(); actions.sendShortcut(s.keys) }
}
}
return rows
}
/** The sheet as a scrollable bottom panel; the pad's cursor row is tinted. */
@Composable
private fun RingSheet(state: RingState, rows: List<SheetRowSpec>, haptics: ConsoleHaptics, modifier: Modifier) {
val scroll = rememberScrollState()
Column(
modifier
.fillMaxWidth(0.92f)
.padding(bottom = 16.dp)
.background(Color.Black.copy(alpha = 0.78f), RoundedCornerShape(16.dp))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.padding(vertical = 8.dp)
.verticalScroll(scroll),
) {
rows.forEachIndexed { i, r ->
r.header?.let { text ->
Text(
text, color = Color.White.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 20.dp, top = 12.dp, bottom = 4.dp),
)
}
Row(
Modifier
.fillMaxWidth()
.background(if (state.sheetCursor == i) Color.White.copy(alpha = 0.12f) else Color.Transparent)
.clickable {
state.touch()
state.sheetCursor = i
if (r.enabled) haptics.tick() else haptics.boundary()
r.onTap()
}
.padding(horizontal = 20.dp, vertical = 12.dp)
.alpha(if (r.enabled) 1f else 0.45f),
) {
Text(r.label, color = Color.White, fontSize = 15.sp, modifier = Modifier.weight(1f))
if (r.value.isNotEmpty()) Text(r.value, color = Color.White.copy(alpha = 0.7f), fontSize = 15.sp)
}
}
Spacer(Modifier.height(4.dp))
}
}
/** The chord as key events: modifiers down in order, the key, modifiers up in reverse. */
fun sendChord(handle: Long, keys: List<String>) {
val vks = keys.mapNotNull { keyVk(it) }
if (vks.size != keys.size || vks.isEmpty()) return
vks.forEach { NativeBridge.nativeSendKey(handle, it, true, 0) }
vks.asReversed().forEach { NativeBridge.nativeSendKey(handle, it, false, 0) }
}
@@ -0,0 +1,78 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* "Send logs to host" this device's log ring ([NativeBridge.nativeRenderLogs], fed by
* `pf_client_core::logring`) posted to a paired host's `POST /api/v1/client-logs` over the same
* mTLS identity the library fetch uses. The bundle is then listed in that host's web console, on
* its Logs page, beside the host's own log.
*
* ONE implementation for both Android shells the Skia console's host menu
* (`console.SkiaConsole`) and the touch home's card menu ([ConnectGrid]). It lived only in the
* console, which made it unreachable on exactly the devices that most need it: a phone whose
* console never comes up has no route to its own logs at all, and the touch UI is the shell a
* reporter is looking at when something is wrong. The wording is the desktop console's verbatim
* (`clients/session/src/console.rs`) so a quoted message means the same thing on every client.
*
* Blocking call it off the main thread.
*/
object SendLogs {
/** `punktfunk-android <ver> (android <rel>; <abi>) — client log bundle`, the desktop's shape. */
fun header(context: Context): String {
val version = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "?"
return "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
}
/** The user-facing outcome: the success line, or "Couldn't send logs — <why>". */
fun toHost(context: Context, identity: ClientIdentity, host: KnownHost): String =
toHost(
context, identity,
addr = host.address, mgmtPort = host.effectiveMgmtPort, fpHex = host.fpHex,
hostName = host.name.ifBlank { host.address },
)
/**
* The address-and-port form, for the console its menu addresses a `HostRow`, which carries
* the mgmt port the ADVERT taught it (fresher than the saved record's).
*/
fun toHost(
context: Context,
identity: ClientIdentity,
addr: String,
mgmtPort: Int,
fpHex: String,
hostName: String,
): String {
val err = runCatching {
val body = NativeBridge.nativeRenderLogs(header(context))
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
identity.certPem, identity.privateKeyPem, addr, fpHex,
)
val req = Request.Builder()
.url("https://$addr:$mgmtPort/api/v1/client-logs")
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { resp ->
// The host answers 201 Created, not 200 — this is a route that STORES a bundle
// (`mgmt/client_logs.rs`). Any 2xx is a success; OkHttp's own predicate spares us
// a second hand-written list of codes to get wrong.
if (resp.isSuccessful) "" else "host answered HTTP ${resp.code}"
}
}.getOrElse { it.message ?: "upload failed" }
return if (err.isEmpty()) {
"Logs sent to $hostName — download them from its web console's Logs page"
} else {
"Couldn't send logs — $err"
}
}
}
@@ -91,6 +91,17 @@ data class Settings(
* Only meaningful while [micEnabled] is on.
*/
val echoCancel: Boolean = true,
/**
* Ask the host to leave ITS OWN audio devices alone for this session
* (`CLIENT_CAP_KEEP_HOST_AUDIO`): it captures whatever its default playback device already is,
* so the speakers or headphones on the host PC keep playing while this device hears the same
* audio. Off the default, and what every build before this did has the host park playback
* on a silent endpoint, which is why the host goes quiet the moment a stream starts.
*
* REQUEST-only: there is no host-cap echo, so an older host ignores the ask and re-routes as it
* always did ("audio still works, the host went quiet"), never a broken session.
*/
val keepHostAudio: Boolean = false,
/**
* How much the in-stream stats overlay shows see [StatsVerbosity]. Defaults to
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
@@ -114,6 +125,22 @@ data class Settings(
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
*/
val gamepadUiEnabled: Boolean = true,
/**
* Draw the console UI at 1080p and let the display scale it up, instead of at the panel's own
* resolution. Off by default this is a deliberate sharpness-for-smoothness trade, not
* something to impose on a device that does not need it.
*
* It exists for 4K TVs and projectors. Their graphics chips are chosen to decode and composite
* video, not to shade a UI, and are far slower than a phone's; at 4K every pass the console
* draws the mesh backdrop above all costs four times what it does at 1080p on hardware
* that is nowhere near four times faster. A "premium" 4K box is MORE likely to want this than
* a cheap 1080p stick, which never had the extra pixels to begin with.
*
* Read by [io.unom.punktfunk.console.SkiaConsoleShell], which applies it with
* `SurfaceHolder.setFixedSize` the compositor then scales the smaller buffer up for free.
* The stream is untouched; that has its own `renderScale`.
*/
val reduceUiResolution: Boolean = false,
/**
* When [gamepadUiEnabled] actually takes over the cross-client `gamepad_ui_mode` pair,
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
@@ -248,6 +275,11 @@ data class Settings(
* the Apple/GTK clients' "Invert scroll direction".
*/
val invertScroll: Boolean = false,
/**
* The in-stream quick-action ring, one JSON blob parsed by [OverlayConfig.parse] (six slots,
* shortcuts, the virtual pad's preset). Empty = the platform default ring.
*/
val overlayActions: String = "",
// NOTE: clipboard sync is NOT here. It is a decision about a HOST, not about this device or
// this stream (design/client-settings-profiles.md §3, tier H), so it lives on the host record
// — see `KnownHost.clipboardSync`. It used to be a global here; `KnownHostStore.migrate`
@@ -314,6 +346,7 @@ class SettingsStore(context: Context) {
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
keepHostAudio = prefs.getBoolean(K_KEEP_HOST_AUDIO, false),
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
@@ -329,6 +362,7 @@ class SettingsStore(context: Context) {
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
reduceUiResolution = prefs.getBoolean(K_REDUCE_UI_RES, false),
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
?: GAMEPAD_UI_WHEN_CONNECTED,
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
@@ -350,6 +384,7 @@ class SettingsStore(context: Context) {
// lands where it already was.
?: if (prefs.getBoolean(K_POINTER_CAPTURE, false)) MouseMode.CAPTURE else MouseMode.DESKTOP,
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
overlayActions = prefs.getString(K_OVERLAY_ACTIONS, "") ?: "",
)
fun save(s: Settings) {
@@ -370,9 +405,11 @@ class SettingsStore(context: Context) {
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
.putBoolean(K_KEEP_HOST_AUDIO, s.keepHostAudio)
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_REDUCE_UI_RES, s.reduceUiResolution)
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
@@ -388,6 +425,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.putString(K_OVERLAY_ACTIONS, s.overlayActions)
.apply()
}
@@ -408,6 +446,7 @@ class SettingsStore(context: Context) {
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_ECHO_CANCEL = "echo_cancel"
const val K_KEEP_HOST_AUDIO = "keep_host_audio"
const val K_STATS_VERBOSITY = "stats_verbosity"
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced read once for migration, never
@@ -415,6 +454,7 @@ class SettingsStore(context: Context) {
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_REDUCE_UI_RES = "reduce_ui_resolution"
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
@@ -444,6 +484,7 @@ class SettingsStore(context: Context) {
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
const val K_POINTER_CAPTURE = "pointer_capture"
const val K_INVERT_SCROLL = "invert_scroll"
const val K_OVERLAY_ACTIONS = "overlay_actions"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode"
@@ -131,6 +131,7 @@ fun SettingsScreen(
var showLicenses by remember { mutableStateOf(false) }
var showControllers by remember { mutableStateOf(false) }
var showQuickActions by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<EditIntent?>(null) }
var deleting by remember { mutableStateOf<StreamProfile?>(null) }
@@ -196,6 +197,20 @@ fun SettingsScreen(
ControllersScreen(gamepadSetting = s.gamepad, onBack = { showControllers = false })
return
}
if (showQuickActions) {
QuickActionsScreen(
blob = s.overlayActions,
onChange = { update(s.copy(overlayActions = it)) },
// Reset drops the override in profile scope (design §3.3) and clears the global
// otherwise; an empty blob is the platform default.
onReset = {
if (active != null) resetField("overlay_actions") else update(s.copy(overlayActions = ""))
},
onBack = { showQuickActions = false },
overridden = active?.overrides?.overridden()?.contains("overlay_actions") == true,
)
return
}
// Selected category persists across rotation (stored by name — null = the bare list on a phone).
var selectedName by rememberSaveable { mutableStateOf(initialCategory?.name) }
@@ -262,6 +277,7 @@ fun SettingsScreen(
context = context,
onMicChange = onMicChange,
onOpenControllers = { showControllers = true },
onOpenQuickActions = { showQuickActions = true },
onOpenLicenses = { showLicenses = true },
onBack = back,
)
@@ -528,6 +544,7 @@ private fun CategoryDetail(
context: android.content.Context,
onMicChange: (Boolean) -> Unit,
onOpenControllers: () -> Unit,
onOpenQuickActions: () -> Unit,
onOpenLicenses: () -> Unit,
onBack: (() -> Unit)?,
) {
@@ -549,7 +566,7 @@ private fun CategoryDetail(
when (category) {
SettingsCategory.General -> GeneralSettings(settings, onChange)
SettingsCategory.Display -> DisplaySettings(settings, onChange, context)
SettingsCategory.Input -> InputSettings(settings, onChange)
SettingsCategory.Input -> InputSettings(settings, onChange, onOpenQuickActions)
SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange)
SettingsCategory.Controllers -> ControllerSettings(settings, onChange, onOpenControllers)
SettingsCategory.About -> AboutSettings(context, onOpenLicenses)
@@ -589,7 +606,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
)
}
SettingsGroup("Interface") {
// The footer is null on every device where the console works, so it costs nothing there —
// and on the ones where it doesn't, it is the only place the app admits that this switch
// is being overruled. See `SkiaConsole.unavailable`.
SettingsGroup("Interface", footer = io.unom.punktfunk.console.SkiaConsole.unavailable()) {
ToggleRow(
title = "Controller-optimized UI",
subtitle = "Swap the touch home for the console home — the host carousel and " +
@@ -782,7 +802,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
}
@Composable
private fun InputSettings(s: Settings, update: (Settings) -> Unit) {
private fun InputSettings(s: Settings, update: (Settings) -> Unit, onOpenQuickActions: () -> Unit) {
SettingsGroup("Touch & pointer") {
SettingDropdown(
label = "Touch input",
@@ -792,6 +812,15 @@ private fun InputSettings(s: Settings, update: (Settings) -> Unit) {
caption = "Trackpad moves the cursor by relative swipes; Direct pointer jumps it " +
"to your finger; Passthrough sends real multi-touch.",
) { mode -> update(s.copy(touchMode = mode)) }
Column {
OverrideBadge("overlay_actions")
ClickableRow(
title = "Quick actions",
subtitle = "Which actions the in-stream ring offers and the shortcuts it can " +
"send; a profile that changes it owns the whole ring",
onClick = onOpenQuickActions,
)
}
}
SettingsGroup("Keyboard & mouse") {
SettingDropdown(
@@ -841,6 +870,13 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
"otherwise the session stays on Opus, which is already effectively " +
"transparent. The overlay shows what a session actually got.",
) { f -> update(s.copy(audioFormat = f)) }
ToggleRow(
title = "Keep host audio playing",
subtitle = "The host's speakers or headphones keep playing while you stream",
checked = s.keepHostAudio,
field = "keep_host_audio",
onCheckedChange = { on -> update(s.copy(keepHostAudio = on)) },
)
ToggleRow(
title = "Microphone",
subtitle = "Feeds this device's microphone to the host",
@@ -908,11 +944,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "What the app detects, with a live input test",
onClick = onOpenControllers,
)
// Rumble mirroring needs a body vibrator to mirror ONTO — a TV box has none, so the row
// would be a silent no-op there.
// Both rows below say "this phone" and mean this device's own body — so they are gated
// on the FORM FACTOR first, and only then on the hardware.
//
// The hardware probe alone was not enough. A TV box was assumed to answer no to both;
// a Shield answers yes to both (field report, #449). The likely route is the attached
// controller — a default `Vibrator` and a SensorManager gyroscope that belong to the
// pad, not to a body the box doesn't have — but the form factor is the honest gate
// either way, because these rows promise something a TV cannot do.
//
// Gated here rather than inside `deviceBodyVibrator`: its other two callers — the
// console's menu haptics and the in-stream mirror — want exactly the vibrator it
// returns today, whatever the form factor.
val context = LocalContext.current
val tv = remember { isTvDevice(context) }
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
if (hasBodyVibrator) {
if (!tv && hasBodyVibrator) {
ToggleRow(
title = "Rumble on this phone",
subtitle = "Also play controller 1's rumble on this phone's motor",
@@ -921,9 +968,9 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
)
}
// The rumble mirror's sibling, data flowing the other way: needs a gyroscope to
// mirror FROM — a TV box has none, so the row would be a silent no-op there.
// mirror FROM, and a body to tilt.
val hasGyroscope = remember { DeviceGyro.available(context) }
if (hasGyroscope) {
if (!tv && hasGyroscope) {
ToggleRow(
title = "Gyro from this phone",
subtitle = "When the controller has no gyro, send this phone's motion " +
@@ -1017,7 +1064,7 @@ private fun AboutSettings(context: android.content.Context, onOpenLicenses: () -
* per-field guidance lives on the fields themselves.
*/
@Composable
private fun SettingsGroup(
internal fun SettingsGroup(
header: String? = null,
footer: String? = null,
content: @Composable ColumnScope.() -> Unit,
@@ -1118,7 +1165,7 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> SettingDropdown(
internal fun <T> SettingDropdown(
label: String,
options: List<Pair<T, String>>,
selected: T,
@@ -45,13 +45,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
@@ -63,10 +67,14 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.kit.DeviceGyro
import io.unom.punktfunk.kit.DsCapture
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.IdentityLoad
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.PadSensors
import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.SessionAccess
@@ -75,7 +83,10 @@ import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The immersive stream. Everything it reads about the session comes from [session] the settings
@@ -242,7 +253,43 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val touchMode = initialSettings.touchMode
// Passthrough needs a host that injects touch; without the bit every contact would vanish, so
// the session runs the trackpad model instead and `touchHint` below says so, once.
val touchUnsupported = remember(handle) {
initialSettings.touchMode == TouchMode.TOUCH && !NativeBridge.nativeHostSupportsTouch(handle)
}
// Live: the ring's Touch mode slot cycles it mid-stream (the gesture layer is keyed on it,
// so a change applies from the next gesture — trap 2 in the design: never mid-gesture).
var touchMode by remember(handle) {
mutableStateOf(if (touchUnsupported) TouchMode.TRACKPAD else initialSettings.touchMode)
}
val hostAcceptsTouch = remember(handle) { NativeBridge.nativeHostSupportsTouch(handle) }
// The quick-action ring (design/touch-client-overlay.md §2), declared ahead of the pad
// router that opens and drives it.
val ring = remember(handle) { RingState() }
var containerSize by remember { mutableStateOf(IntSize.Zero) }
val haptics = rememberConsoleHaptics()
val overlayCfg = remember(initialSettings.overlayActions) { OverlayConfig.parse(initialSettings.overlayActions) }
// The virtual controller (design §4): shown from the ring's `pad` slot, per session. While
// up it holds one wire pad on the router, so the host sees one controller arrive and, on
// hide, one leave (§9). Never toggled by the ring's own open and close (§8 trap 4).
var padShown by remember(handle) { mutableStateOf(false) }
var virtualPad by remember(handle) { mutableStateOf<GamepadRouter.ExternalPad?>(null) }
DisposableEffect(padShown) {
val ext = if (padShown) activity?.gamepadRouter?.openExternal(Gamepad.PREF_XBOX360) else null
virtualPad = ext
onDispose {
ext?.close()
virtualPad = null
}
}
var touchHint by remember { mutableStateOf(touchUnsupported) }
LaunchedEffect(touchHint) {
if (touchHint) {
delay(6000)
touchHint = false
}
}
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
// fast pipeline — decoder ranking + vendor keys + async loop (native side), HDMI ALLM below,
// game-tagged audio, and DSCP marking (applied earlier, at connect); off falls back to the
@@ -499,6 +546,13 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// local on purpose: this mirrors the tap exactly (`onCycleStats` below), and the settings
// row calls it a live cycle — the stored default is what the next stream starts from.
router.onStatsChord = { statsVerbosity = statsVerbosity.next() }
// `Select+A` opens the ring at the screen centre; while it is up the pad belongs to it.
router.onRingChord = {
haptics.confirm()
ring.openAt(Offset(containerSize.width / 2f, containerSize.height / 2f))
}
router.onRingNav = { ring.nav(it) }
ring.onOpenChange = { open -> router.setRingOpen(open) }
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
// The local cursor is hidden over the stream — the host's own cursor, composited into
@@ -786,6 +840,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.onMicChord = null // same: no mute toggle on buttons released during teardown
router.onStatsChord = null // same: no tier cycle on buttons released during teardown
router.onRingChord = null
router.onRingNav = null
ring.onOpenChange = null
router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
@@ -831,8 +888,43 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
}
}
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
// The quick-action ring (design/touch-client-overlay.md §2). Back opens it at the screen
// centre instead of ending the session — an edge swipe mid-game used to tear the session down
// with no confirmation (§5.3). "End stream" is a slot inside, behind a two-press arm.
BackHandler {
when {
ring.sheet -> ring.sheet = false
ring.committed -> ring.close()
else -> ring.openAt(Offset(containerSize.width / 2f, containerSize.height / 2f))
}
}
// Host actions are PRE-FETCHED on the session tick, never fetched when the ring opens: two
// of these buttons shut a machine down, and buttons that appear under a moving finger are a
// hazard. Empty toward an older host, an unreachable one, or without the record.
var hostActions by remember(handle) { mutableStateOf<List<HostActions.Action>>(emptyList()) }
val hostRecord = remember(session.hostId) {
session.hostId?.let { id -> KnownHostStore(context).all().firstOrNull { it.id == id } }
}
LaunchedEffect(handle) {
val kh = hostRecord ?: return@LaunchedEffect
if (kh.fpHex.isEmpty()) return@LaunchedEffect
val identity = withContext(Dispatchers.IO) {
(IdentityStore(context).load() as? IdentityLoad.Ok)?.identity
} ?: return@LaunchedEffect
while (true) {
hostActions = withContext(Dispatchers.IO) {
HostActions.list(identity, kh.address, kh.effectiveMgmtPort, kh.fpHex)
}
delay(300_000)
}
}
// The live session mode: `nativeVideoSize` follows an accepted mode switch, but its ack lands
// off the composition, so a request writes the asked-for mode here at once and re-reads the
// truth shortly after (a rejection shows through then).
var requestedMode by remember(handle) {
mutableStateOf(NativeBridge.nativeVideoSize(handle)?.takeIf { it.size >= 2 } ?: intArrayOf(0, 0, 60))
}
val scope = rememberCoroutineScope()
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
// suspend a process for going to background, so without this the native worker kept running and
@@ -872,12 +964,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
val h = size?.getOrNull(1) ?: 0
if (w > 0 && h > 0) w.toFloat() / h.toFloat() else 0f
}
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
// One rect for the picture AND for the input that lands on it. Every absolute mapping —
// direct-pointer touch, multi-touch passthrough, the pen lane — measures against the size of
// the node it sits on, so putting the gesture layer on this same rect keeps all three correct
// by construction rather than by threading an offset through each of them. The cost is that
// trackpad swipes starting inside a letterbox bar don't register; the picture is the surface.
Box(modifier = Modifier.fillMaxSize().background(Color.Black).onSizeChanged { containerSize = it }) {
// The picture is aspect-fitted; the gesture layer below spans the WHOLE container and maps
// every absolute contact — direct-pointer touch, passthrough, the pen lane — into this same
// fit through `videoFitRect`, so a swipe that starts on a letterbox bar still registers and
// a contact on a bar lands on the nearest picture edge.
val videoFit = if (videoAspect > 0f) {
Modifier.align(Alignment.Center).aspectRatio(videoAspect)
} else {
@@ -1001,12 +1092,15 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
}
}
// The Access chip — what this session is allowed to do, said in the preset vocabulary
// ("Controller only · 1 h 58 m left"), standing for the whole stream. Full control with
// no expiry — every session against an old host, and most against a new one — shows
// NOTHING: the chip exists for the sessions where input silently not landing needs an
// explanation, not as new chrome on everyone's stream. TopEnd, in the shared pill family
// (TopStart is the HUD's, TopCentre the transient cues', BottomCentre the banner's).
// ("Controller only · 1 h 58 m left"), shown while the stats HUD is on. It rides the
// stats tier rather than standing for the whole stream: a pill that never goes away is
// chrome you read as distraction. Full control with no expiry — every session against an
// old host, and most against a new one — shows NOTHING: the chip exists for the sessions
// where input silently not landing needs an explanation, not as new chrome on everyone's
// stream. TopEnd, in the shared pill family (TopStart is the HUD's, TopCentre the
// transient cues', BottomCentre the banner's).
val accessChip = when {
!statsOn -> null
accessGrants and SessionAccess.ALL == SessionAccess.ALL && accessRemaining == 0 -> null
accessRemaining > 0 ->
"${SessionAccess.label(accessGrants)} · " +
@@ -1039,7 +1133,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// and names the setting that fixes it, while the banner repeats shortcuts that will be
// there next stream too. Two pills sharing an edge for six seconds would cost the reader
// both.
if (bannerUp && !motionHint) {
if (bannerUp && !motionHint && !touchHint) {
StreamStartBanner(
text = buildList {
if (padPresent) {
@@ -1103,13 +1197,14 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
}
Box(
videoFit.pointerInput(handle, touchMode, pointerOk) {
Modifier.fillMaxSize().pointerInput(handle, touchMode, pointerOk) {
when {
!pointerOk -> {} // no capture — the Access chip is what says why
touchMode == TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
touchMode == TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus, videoAspect)
else -> streamTouchInput(
handle,
stylus,
videoAspect,
trackpad = touchMode == TouchMode.TRACKPAD,
invertScroll = initialSettings.invertScroll,
onCycleStats = { statsVerbosity = statsVerbosity.next() },
@@ -1120,6 +1215,15 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
keyCapture?.setImeVisible(show)
}
},
// The two-finger twist turns the quick-action ring, frame by frame.
onDial = { ev ->
when (ev) {
is DialEvent.Turn ->
if (ring.turn(ev.progress, ev.clockwise, ev.x, ev.y)) haptics.tick()
DialEvent.Commit -> { ring.commit(); haptics.confirm() }
DialEvent.Cancel -> ring.cancel()
}
},
)
}
},
@@ -1130,15 +1234,92 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// Chord confirmation (gamepad/TV) — mute has no standing indicator, so this is the whole
// of its feedback: a toggle that showed nothing at all would be indistinguishable from one
// that never registered.
// The virtual controller: above the gesture layer, so its controls take their fingers
// first and every other finger falls through; below the ring, whose scrim owns every
// finger while it is up. Composed only while shown (tenet 1).
virtualPad?.let { ext ->
val sink = remember(ext) { PadSink(ext::button, ext::axis) }
VirtualPadLayer(overlayCfg.pad, containerSize, sink, haptics)
}
// The ring, above the gesture layer so its buttons take the finger first. Composed only
// while open: a closed overlay costs nothing (tenet 1).
RingOverlay(
state = ring,
cfg = overlayCfg,
actions = RingActions(
endStream = { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) },
disconnectLinger = { onSessionEnded(SessionEndReason.LOCAL) },
touchMode = { touchMode },
cycleTouchMode = {
// Passthrough is skipped toward a host that drops contacts (§5.4).
val order = if (hostAcceptsTouch) TouchMode.entries else listOf(TouchMode.TRACKPAD, TouchMode.POINTER)
touchMode = order[(order.indexOf(touchMode) + 1) % order.size]
},
keyboardGranted = { accessGrants and SessionAccess.KEYBOARD != 0 },
keyboard = { keyCapture?.setImeVisible(true) },
textSupported = NativeBridge.nativeTextInputSupported(handle),
sendText = { NativeBridge.nativeSendText(handle, it) },
stats = { statsVerbosity },
cycleStats = { statsVerbosity = statsVerbosity.next() },
micAvailable = { micRunning },
micMuted = { micMuted },
toggleMic = { setMicMuted(!micMuted) },
hostActions = { hostActions },
invokeHost = { act ->
hostRecord?.let { kh ->
scope.launch(Dispatchers.IO) {
(IdentityStore(context).load() as? IdentityLoad.Ok)?.identity?.let { id ->
HostActions.invoke(id, kh.address, kh.effectiveMgmtPort, kh.fpHex, kh.name, act.id, act.label)
}
}
}
},
sendShortcut = { sendChord(handle, it) },
padAvailable = { activity?.gamepadRouter?.sendsEnabled() == true },
padShown = { padShown },
togglePad = { padShown = !padShown },
currentMode = { requestedMode },
requestMode = { w, h, hz ->
if (NativeBridge.nativeRequestMode(handle, w, h, hz)) {
requestedMode = intArrayOf(w, h, hz)
scope.launch {
delay(500)
NativeBridge.nativeVideoSize(handle)?.takeIf { it.size >= 3 }?.let { requestedMode = it }
}
}
},
),
containerSize = containerSize,
haptics = haptics,
)
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
// notice landing on top of one of those would cost the user both.
if (motionHint) {
MotionUnreachableHint(Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp))
} else if (touchHint) {
TouchFallbackHint(Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp))
}
}
}
/**
* "This host doesn't accept touch" shown briefly when the Touch (passthrough) model meets a host
* whose injector drops contacts (no `HOST_CAP2_TOUCH`). The session runs the trackpad model
* instead; without this line the user would see their setting silently ignored.
*/
@Composable
private fun TouchFallbackHint(modifier: Modifier = Modifier) {
Text(
"This host doesn't accept touch — using the trackpad model",
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/**
* Attach the Java echo-canceller + noise-suppressor pair to the mic stream's audio session the
* backstop for HALs whose VoiceCommunication capture path doesn't cancel on its own (the native
@@ -4,7 +4,7 @@ import android.view.MotionEvent
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.IntRect
import io.unom.punktfunk.kit.NativeBridge
import kotlinx.coroutines.delay
@@ -50,14 +50,14 @@ internal class StylusStream(private val handle: Long) {
* carried any (the caller's finger/gesture handling must then skip those changes).
*/
@OptIn(ExperimentalComposeUiApi::class)
fun intercept(ev: PointerEvent, size: IntSize): Boolean {
fun intercept(ev: PointerEvent, rect: IntRect): Boolean {
val stylusChanges = ev.changes.filter {
it.type == PointerType.Stylus || it.type == PointerType.Eraser
}
if (stylusChanges.isEmpty()) return false
stylusChanges.forEach { it.consume() }
val me = ev.motionEvent ?: return true
if (size.width <= 0 || size.height <= 0) return true
if (rect.width <= 0 || rect.height <= 0) return true
// At most one stylus exists — find its pointer index by tool type.
val idx = (0 until me.pointerCount).firstOrNull {
me.getToolType(it) == MotionEvent.TOOL_TYPE_STYLUS ||
@@ -70,20 +70,20 @@ internal class StylusStream(private val handle: Long) {
-> {
touching = true
inRange = true
emitSamples(me, idx, size)
emitSamples(me, idx, rect)
}
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE -> {
sawHover = true
inRange = true
touching = false
emitSamples(me, idx, size)
emitSamples(me, idx, rect)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> {
touching = false
// Hover-capable hardware keeps proximity (HOVER_EXIT owns the leave);
// anything else leaves range on lift — the host never parks a phantom pen.
inRange = sawHover
emitSamples(me, idx, size)
emitSamples(me, idx, rect)
}
MotionEvent.ACTION_HOVER_EXIT, MotionEvent.ACTION_CANCEL -> release()
else -> {}
@@ -123,14 +123,14 @@ internal class StylusStream(private val handle: Long) {
/** Historical (coalesced) samples oldest-first, then the current one one emit; the JNI
* layer splits runs longer than the wire's 8-sample batch cap into consecutive sends. */
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
private fun emitSamples(me: MotionEvent, idx: Int, rect: IntRect) {
val history = minOf(me.historySize, MAX_SAMPLES - 1)
var count = 0
var prevT = if (history > 0) me.getHistoricalEventTime(0) else me.eventTime
for (h in (me.historySize - history) until me.historySize) {
val t = me.getHistoricalEventTime(h)
fill(
batch, count * STRIDE, size,
batch, count * STRIDE, rect,
x = me.getHistoricalX(idx, h), y = me.getHistoricalY(idx, h),
pressure = me.getHistoricalPressure(idx, h),
tiltRad = me.getHistoricalAxisValue(MotionEvent.AXIS_TILT, idx, h),
@@ -143,7 +143,7 @@ internal class StylusStream(private val handle: Long) {
count++
}
fill(
batch, count * STRIDE, size,
batch, count * STRIDE, rect,
x = me.getX(idx), y = me.getY(idx), pressure = me.getPressure(idx),
tiltRad = me.getAxisValue(MotionEvent.AXIS_TILT, idx),
orientRad = me.getAxisValue(MotionEvent.AXIS_ORIENTATION, idx),
@@ -159,7 +159,7 @@ internal class StylusStream(private val handle: Long) {
private fun fill(
out: FloatArray,
off: Int,
size: IntSize,
rect: IntRect,
x: Float,
y: Float,
pressure: Float,
@@ -177,8 +177,9 @@ internal class StylusStream(private val handle: Long) {
if (buttons and MotionEvent.BUTTON_STYLUS_SECONDARY != 0) state += PEN_BARREL2
out[off + 0] = state
out[off + 1] = if (tool == MotionEvent.TOOL_TYPE_ERASER) 1f else 0f
out[off + 2] = (x / (size.width - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
out[off + 3] = (y / (size.height - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
// Normalised against the PICTURE rect (a contact on a letterbox bar clamps to its edge).
out[off + 2] = ((x - rect.left) / (rect.width - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
out[off + 3] = ((y - rect.top) / (rect.height - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
out[off + 4] = if (touching) pressure.coerceIn(0f, 1f) else 0f
// AXIS_DISTANCE units are device-arbitrary; 0..1 covers real hardware, and 0 while
// hovering legitimately means "at the hover floor".
@@ -1,5 +1,6 @@
package io.unom.punktfunk
import android.os.SystemClock
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerId
@@ -9,18 +10,42 @@ import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.hypot
import kotlin.math.roundToInt
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
// two-finger pan per wheel notch (smaller = faster scroll).
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. LONG_PRESS_MS:
// one finger held still this long presses the left button and drags until it lifts. SCROLL_DIV:
// px of two-finger pan per wheel notch (smaller = faster scroll).
private const val TAP_SLOP = 12f
private const val TAP_DRAG_MS = 250L
private const val LONG_PRESS_MS = 500L
private const val SCROLL_DIV = 4f
// The dial (design/touch-client-overlay.md §2.1): a two-finger TWIST opens the quick-action ring.
// DIAL_ARM_DEG: below this rotation the gesture is still a scroll candidate — natural scrolls
// rotate a few degrees, and this is what absorbs them. DIAL_COMMIT_DEG: the ring commits and stays
// open after the fingers lift. DIAL_SLOP: centroid travel beyond this before arming means scroll.
private const val DIAL_ARM_DEG = 10f
private const val DIAL_COMMIT_DEG = 30f
private const val DIAL_SLOP = 2 * TAP_SLOP
/** The twist's progress, for the ring: [Turn] on every move once armed, then [Commit] at the
* commit angle, or [Cancel] when the fingers lift short of it (or wind it back). */
sealed class DialEvent {
/** [progress] 01 drives the ring's unwind; [clockwise] is the hand's direction; [x]/[y]
* (container px) the centroid the ring is centred on. */
data class Turn(val progress: Float, val clockwise: Boolean, val x: Float, val y: Float) : DialEvent()
object Commit : DialEvent()
object Cancel : DialEvent()
}
// Three-finger vertical swipe: the fraction of the view height the centroid must travel to
// summon (up) / dismiss (down) the local soft keyboard.
private const val KB_SWIPE_FRACTION = 0.10f
@@ -45,8 +70,8 @@ private const val ACCEL_MAX = 3.0f
* host-normalized against the overlay size), the old "direct pointing" behaviour.
*
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier);
* two-finger drag = scroll; tap-then-press-and-drag OR press-and-hold-then-drag = left-drag
* (text selection / moving windows); three-finger tap = [onCycleStats] (cycle the stats-HUD tier);
* three-finger swipe up/down = [onKeyboard] (summon/dismiss the local soft keyboard, for
* typing on the host).
*/
@@ -58,6 +83,32 @@ private const val ACCEL_MAX = 3.0f
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
* contact is lifted so nothing stays stuck on the host.
*/
/**
* The picture's rect inside a container of [size] for a stream of [aspect] (width / height): the
* same centre-aligned aspect fit the video surface is laid out with. The gesture layer spans the
* whole container so a finger on a letterbox bar still counts, and every absolute mapping
* direct pointer, passthrough, the pen lane measures against this rect, clamped, because a
* contact outside the picture has no host position of its own. `aspect <= 0` (unknown) fills.
*/
internal fun videoFitRect(size: IntSize, aspect: Float): IntRect {
val w = size.width
val h = size.height
if (aspect <= 0f || w <= 0 || h <= 0) return IntRect(IntOffset.Zero, size)
return if (w.toFloat() / h > aspect) {
val vw = (h * aspect).roundToInt() // wider container: bars left and right
val left = (w - vw) / 2
IntRect(left, 0, left + vw, h)
} else {
val vh = (w / aspect).roundToInt() // taller container: bars top and bottom
val top = (h - vh) / 2
IntRect(0, top, w, top + vh)
}
}
/** [x] in container pixels → picture-surface pixels, clamped to the picture's edge. */
private fun IntRect.clampX(x: Float): Int = (x - left).roundToInt().coerceIn(0, width - 1)
private fun IntRect.clampY(y: Float): Int = (y - top).roundToInt().coerceIn(0, height - 1)
/** Whether this change belongs to the stylus lane (only when a pen-capable host is live). */
private fun isStylus(c: PointerInputChange, stylus: StylusStream?): Boolean =
stylus != null && (c.type == PointerType.Stylus || c.type == PointerType.Eraser)
@@ -66,10 +117,11 @@ private fun isStylus(c: PointerInputChange, stylus: StylusStream?): Boolean =
* mouse/touch gesture. Toward a pen-less host ([stylus] == null) a stylus stays a finger. */
private suspend fun AwaitPointerEventScope.awaitFirstFingerDown(
stylus: StylusStream?,
videoAspect: Float,
): PointerInputChange {
while (true) {
val ev = awaitPointerEvent()
stylus?.intercept(ev, size)
stylus?.intercept(ev, videoFitRect(size, videoAspect))
val down = ev.changes.firstOrNull {
it.changedToDownIgnoreConsumed() && !isStylus(it, stylus)
}
@@ -77,7 +129,11 @@ private suspend fun AwaitPointerEventScope.awaitFirstFingerDown(
}
}
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, stylus: StylusStream?) {
internal suspend fun PointerInputScope.streamTouchPassthrough(
handle: Long,
stylus: StylusStream?,
videoAspect: Float,
) {
val ids = mutableMapOf<PointerId, Int>()
fun alloc(p: PointerId): Int {
var id = 0
@@ -89,14 +145,15 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
awaitPointerEventScope {
while (true) {
val ev = awaitPointerEvent()
stylus?.intercept(ev, size)
val sw = size.width
val sh = size.height
val r = videoFitRect(size, videoAspect)
stylus?.intercept(ev, r)
val sw = r.width
val sh = r.height
if (sw <= 0 || sh <= 0) continue
for (c in ev.changes) {
if (isStylus(c, stylus)) continue // the pen plane owns it
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
val x = r.clampX(c.position.x)
val y = r.clampY(c.position.y)
when {
c.changedToDownIgnoreConsumed() ->
NativeBridge.nativeSendTouch(handle, alloc(c.id), 0, x, y, sw, sh)
@@ -113,8 +170,7 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
for (hs in c.historical) {
NativeBridge.nativeSendTouch(
handle, id, 1,
hs.position.x.roundToInt().coerceIn(0, sw - 1),
hs.position.y.roundToInt().coerceIn(0, sh - 1),
r.clampX(hs.position.x), r.clampY(hs.position.y),
sw, sh,
)
}
@@ -134,29 +190,24 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
internal suspend fun PointerInputScope.streamTouchInput(
handle: Long,
stylus: StylusStream?,
videoAspect: Float,
trackpad: Boolean,
invertScroll: Boolean,
onCycleStats: () -> Unit,
onKeyboard: (show: Boolean) -> Unit,
onDial: (DialEvent) -> Unit,
) {
val scrollDir = if (invertScroll) -1 else 1
var lastTapUp = 0L
var lastTapX = 0f
var lastTapY = 0f
fun moveAbs(x: Float, y: Float) {
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) return
NativeBridge.nativeSendPointerAbs(
handle,
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
sw,
sh,
)
val r = videoFitRect(size, videoAspect)
if (r.width <= 0 || r.height <= 0) return
NativeBridge.nativeSendPointerAbs(handle, r.clampX(x), r.clampY(y), r.width, r.height)
}
awaitEachGesture {
val down = awaitFirstFingerDown(stylus)
val down = awaitFirstFingerDown(stylus, videoAspect)
val startX = down.position.x
val startY = down.position.y
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
@@ -168,11 +219,26 @@ internal suspend fun PointerInputScope.streamTouchInput(
// whole point — you nudge it with swipes instead).
if (!trackpad) moveAbs(startX, startY)
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
// The left button this gesture holds (tap-drag from the start, or a long press later);
// released exactly once, in the `finally`, so a teardown mid-drag never strands it.
var dragHeld = isDrag
val downT = down.uptimeMillis
var moved = false
var maxFingers = 1
var scrolling = false
var scrollCount = 0 // pointer count the scroll centroid is anchored at
// A scroll notch went on the wire: a scroll for the gesture's lifetime, never a dial.
var scrollEmitted = false
// The twist: the finger-to-finger vector when the pair formed, the centroid then, and
// whether it has armed (owns the gesture) / committed (the ring stays open).
var dialIds: Pair<PointerId, PointerId>? = null
var dialVx = 0f
var dialVy = 0f
var dialAnchorX = 0f
var dialAnchorY = 0f
var dialArmed = false
var dialCommitted = false
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the
// scroll anchor) and a once-per-gesture latch.
var kbCount = 0
@@ -191,142 +257,220 @@ internal suspend fun PointerInputScope.streamTouchInput(
var accX = 0f
var accY = 0f
while (true) {
val ev = awaitPointerEvent()
stylus?.intercept(ev, size)
val pressed = ev.changes.filter { it.pressed && !isStylus(it, stylus) }
if (pressed.isEmpty()) {
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
break
}
if (pressed.size > maxFingers) maxFingers = pressed.size
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
// bounce re-anchors instead of reading the count change as swipe travel.
if (pressed.size < 3) kbCount = 0
if (pressed.size == 2) {
// Two fingers → scroll by the centroid delta; never move the cursor.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
// centroid of three fingers sits far from the centroid of two, and real fingers
// never land (or lift) in the same input frame — so the 2→3 transition would
// otherwise read as a scroll notch, sending a phantom wheel tick to the host AND
// setting `moved`, which disqualified the tap classification below and made the
// 3-finger stats tap unreachable on real hardware.
if (!scrolling || pressed.size != scrollCount) {
scrolling = true
scrollCount = pressed.size
prevCx = cx
prevCy = cy
}
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
if (sy != 0) {
NativeBridge.nativeSendScroll(handle, 0, sy * 120 * scrollDir)
prevCy = cy
moved = true
}
if (sx != 0) {
NativeBridge.nativeSendScroll(handle, 1, sx * 120 * scrollDir)
prevCx = cx
moved = true
}
} else if (pressed.size >= 3) {
// Three+ fingers → the keyboard swipe, never scroll (the documented
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
// accident of its old `>= 2` bound). Anchor the centroid per finger count
// (same reasoning as the scroll anchor above) and fire once per gesture when
// the vertical travel crosses the threshold: up = show, down = hide.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (pressed.size != kbCount) {
kbCount = pressed.size
kbAnchorX = cx
kbAnchorY = cy
try {
while (true) {
// A still finger raises no event, so the long press is a timeout: while one finger
// is down and nothing has moved, wait at most until the hold time; running out
// means "held still that long" and picks up the drag.
val ev = if (!dragHeld && !moved && maxFingers == 1) {
val remaining = LONG_PRESS_MS - (SystemClock.uptimeMillis() - downT)
if (remaining <= 0) null else withTimeoutOrNull(remaining) { awaitPointerEvent() }
} else {
val dy = cy - kbAnchorY
// Real centroid travel disqualifies the tap classification below (else a
// sub-threshold swipe would still fire the three-finger stats tap).
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
kbFired = true
onKeyboard(dy < 0) // finger up → show, finger down → hide
awaitPointerEvent()
}
if (ev == null) {
dragHeld = true
NativeBridge.nativeSendPointerButton(handle, 1, true)
continue
}
stylus?.intercept(ev, videoFitRect(size, videoAspect))
val pressed = ev.changes.filter { it.pressed && !isStylus(it, stylus) }
.sortedBy { it.id.value }
// Any change of the pair ends the twist: a lift short of commit winds the ring
// back in; a committed ring stays open and the UI owns it from here.
if (pressed.size != 2 && dialIds != null) {
if (dialArmed && !dialCommitted) onDial(DialEvent.Cancel)
dialIds = null
dialArmed = false
dialCommitted = false
}
if (pressed.isEmpty()) {
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
break
}
if (pressed.size > maxFingers) maxFingers = pressed.size
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
// bounce re-anchors instead of reading the count change as swipe travel.
if (pressed.size < 3) kbCount = 0
if (pressed.size == 2) {
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
// The dial first (design §2.1): a twist of the finger-to-finger vector past
// DIAL_ARM_DEG with the centroid still owns the gesture; a scroll notch
// already sent, or centroid travel past DIAL_SLOP, means the hand is
// scrolling. A pinch with no rotation is nothing.
val (a, b) = pressed
val ids = a.id to b.id
if (dialIds != ids && !scrollEmitted) {
dialIds = ids
dialVx = b.position.x - a.position.x
dialVy = b.position.y - a.position.y
dialAnchorX = cx
dialAnchorY = cy
}
}
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
// down, so dropping straight back to one finger must re-anchor (zero delta),
// not replay the whole 3-finger phase as one cursor jump.
scrolling = false
scrollCount = 0
trackId = PointerId(Long.MIN_VALUE)
} else if (!scrolling) {
// One finger (skipped once a gesture turned into a scroll, so dropping
// back to one finger doesn't jerk the cursor).
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
if (abs(p.position.x - startX) > TAP_SLOP ||
abs(p.position.y - startY) > TAP_SLOP
) {
moved = true
}
if (trackpad) {
// Relative: move by the finger delta × (sensitivity × acceleration),
// carrying the sub-pixel remainder. Re-anchor (zero delta this frame)
// if the tracked finger changed, so lifting one of several fingers
// never jumps the cursor.
if (p.id != trackId) {
trackId = p.id
if (dialIds == ids && (dialArmed || !scrollEmitted)) {
val vx = b.position.x - a.position.x
val vy = b.position.y - a.position.y
val phi = Math.toDegrees(
atan2(dialVx * vy - dialVy * vx, dialVx * vx + dialVy * vy).toDouble(),
).toFloat() // signed; + = clockwise on a y-down screen
val travel = hypot(cx - dialAnchorX, cy - dialAnchorY)
if (dialArmed || (travel < DIAL_SLOP && abs(phi) >= DIAL_ARM_DEG)) {
if (!dialArmed) {
dialArmed = true
moved = true // a twist is never a tap…
scrolling = true // …and dropping to one finger must not jerk the cursor
}
val p = ((abs(phi) - DIAL_ARM_DEG) / (DIAL_COMMIT_DEG - DIAL_ARM_DEG))
.coerceIn(0f, 1f)
onDial(DialEvent.Turn(p, phi > 0f, cx, cy))
if (p >= 1f && !dialCommitted) {
dialCommitted = true
onDial(DialEvent.Commit)
} else if (p <= 0f && dialCommitted) {
dialCommitted = false
onDial(DialEvent.Cancel)
}
ev.changes.forEach { it.consume() }
continue
}
// Undecided: under DIAL_SLOP of travel and under DIAL_ARM_DEG of turn the
// pair may still become a twist, so no scroll notch goes out yet — a
// notch is final (scrollEmitted) and a real twist drifts its centroid
// past SCROLL_DIV long before it turns 10°. The anchor follows the
// centroid so the scroll starts smoothly once the slop is crossed.
if (!scrollEmitted && travel < DIAL_SLOP) {
scrolling = true
scrollCount = 2
prevCx = cx
prevCy = cy
continue
}
}
// Two fingers → scroll by the centroid delta; never move the cursor.
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
// centroid of three fingers sits far from the centroid of two, and real fingers
// never land (or lift) in the same input frame — so the 2→3 transition would
// otherwise read as a scroll notch, sending a phantom wheel tick to the host AND
// setting `moved`, which disqualified the tap classification below and made the
// 3-finger stats tap unreachable on real hardware.
if (!scrolling || pressed.size != scrollCount) {
scrolling = true
scrollCount = pressed.size
prevCx = cx
prevCy = cy
}
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
if (sy != 0) {
NativeBridge.nativeSendScroll(handle, 0, sy * 120 * scrollDir)
prevCy = cy
moved = true
scrollEmitted = true
}
if (sx != 0) {
NativeBridge.nativeSendScroll(handle, 1, sx * 120 * scrollDir)
prevCx = cx
moved = true
scrollEmitted = true
}
} else if (pressed.size >= 3) {
// Three+ fingers → the keyboard swipe, never scroll (the documented
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
// accident of its old `>= 2` bound). Anchor the centroid per finger count
// (same reasoning as the scroll anchor above) and fire once per gesture when
// the vertical travel crosses the threshold: up = show, down = hide.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (pressed.size != kbCount) {
kbCount = pressed.size
kbAnchorX = cx
kbAnchorY = cy
} else {
val dy = cy - kbAnchorY
// Real centroid travel disqualifies the tap classification below (else a
// sub-threshold swipe would still fire the three-finger stats tap).
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
kbFired = true
onKeyboard(dy < 0) // finger up → show, finger down → hide
}
}
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
// down, so dropping straight back to one finger must re-anchor (zero delta),
// not replay the whole 3-finger phase as one cursor jump.
scrolling = false
scrollCount = 0
trackId = PointerId(Long.MIN_VALUE)
} else if (!scrolling) {
// One finger (skipped once a gesture turned into a scroll, so dropping
// back to one finger doesn't jerk the cursor).
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
if (abs(p.position.x - startX) > TAP_SLOP ||
abs(p.position.y - startY) > TAP_SLOP
) {
moved = true
}
if (trackpad) {
// Relative: move by the finger delta × (sensitivity × acceleration),
// carrying the sub-pixel remainder. Re-anchor (zero delta this frame)
// if the tracked finger changed, so lifting one of several fingers
// never jumps the cursor.
if (p.id != trackId) {
trackId = p.id
prevX = p.position.x
prevY = p.position.y
prevT = p.uptimeMillis
}
val dx = p.position.x - prevX
val dy = p.position.y - prevY
val dt = (p.uptimeMillis - prevT).coerceAtLeast(1L)
prevX = p.position.x
prevY = p.position.y
prevT = p.uptimeMillis
val speed = hypot(dx, dy) / dt // finger px per ms
val accel = (1f + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).coerceAtLeast(0f))
.coerceAtMost(ACCEL_MAX)
accX += dx * POINTER_SENS * accel
accY += dy * POINTER_SENS * accel
val outX = accX.toInt() // truncates toward zero → remainder kept w/ sign
val outY = accY.toInt()
if (outX != 0 || outY != 0) {
NativeBridge.nativeSendPointerMove(handle, outX, outY)
accX -= outX
accY -= outY
}
} else {
// Direct: cursor follows the finger — historical points first (batched
// MotionEvent samples), so the host cursor traces the finger's real path.
for (hs in p.historical) moveAbs(hs.position.x, hs.position.y)
moveAbs(p.position.x, p.position.y)
}
val dx = p.position.x - prevX
val dy = p.position.y - prevY
val dt = (p.uptimeMillis - prevT).coerceAtLeast(1L)
prevX = p.position.x
prevY = p.position.y
prevT = p.uptimeMillis
val speed = hypot(dx, dy) / dt // finger px per ms
val accel = (1f + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).coerceAtLeast(0f))
.coerceAtMost(ACCEL_MAX)
accX += dx * POINTER_SENS * accel
accY += dy * POINTER_SENS * accel
val outX = accX.toInt() // truncates toward zero → remainder kept w/ sign
val outY = accY.toInt()
if (outX != 0 || outY != 0) {
NativeBridge.nativeSendPointerMove(handle, outX, outY)
accX -= outX
accY -= outY
}
} else {
// Direct: cursor follows the finger — historical points first (batched
// MotionEvent samples), so the host cursor traces the finger's real path.
for (hs in p.historical) moveAbs(hs.position.x, hs.position.y)
moveAbs(p.position.x, p.position.y)
}
ev.changes.forEach { it.consume() }
}
ev.changes.forEach { it.consume() }
}
if (isDrag) {
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
} else if (!moved) {
when {
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
maxFingers == 2 -> { // two-finger tap → right click
NativeBridge.nativeSendPointerButton(handle, 3, true)
NativeBridge.nativeSendPointerButton(handle, 3, false)
}
else -> { // tap → left click (at the cursor's current spot), arm tap-drag
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
lastTapUp = upTime
lastTapX = startX
lastTapY = startY
if (!dragHeld && !moved) {
when {
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
maxFingers == 2 -> { // two-finger tap → right click
NativeBridge.nativeSendPointerButton(handle, 3, true)
NativeBridge.nativeSendPointerButton(handle, 3, false)
}
else -> { // tap → left click (at the cursor's current spot), arm tap-drag
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
lastTapUp = upTime
lastTapX = startX
lastTapY = startY
}
}
}
} finally {
if (dragHeld) NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
}
}
}

Some files were not shown because too many files have changed in this diff Show More