Commit Graph
100 Commits
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 ab6d7727d3 fix(ci/runner): the disk guard has to fire before the disk is tight, not after
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 2m23s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
ci / bench (push) Successful in 6m45s
ci / rust-arm64 (push) Successful in 10m32s
ci / rust (push) Failing after 10m39s
decky / build-publish (push) Successful in 38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 22s
docker / build-push-arm64cross (push) Successful in 23s
android / android (push) Successful in 12m39s
docker / deploy-docs (push) Successful in 1m18s
apple / swift (push) Successful in 4m56s
arch / build-publish (push) Successful in 20m44s
apple / screenshots (push) Successful in 22m0s
deb / build-publish-host (push) Successful in 10m47s
deb / build-publish-client-arm64 (push) Successful in 7m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m27s
deb / build-publish (push) Failing after 6m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m40s
Measured over six hours after the last change: zero burst clears fired, while deb
still died of ENOSPC between polls. Both 30 min and 10 min lost the same race —
three concurrent Rust builds fill the disk and drain it again inside the interval,
so every poll landed on a healthy df and the guard concluded all was well.

Two changes, both aimed at that gap rather than at the symptom. The interval goes
to 2 min: the script is a few docker calls and no-ops in about a second, so
sampling five times more often costs nothing worth counting. And MIN_FREE_GB goes
45 -> 60, because the clear only reclaims idle images (~18 G measured) while three
jobs can eat the remainder inside one interval — a guard that waits for "tight"
has already lost. It has to act while there is still room to act in.

This narrows the window; it does not close it. With a 43 G image baseline on a
172 G disk, three concurrent heavy jobs are working inside ~129 G and can exceed
it. Sub-interval spikes are a polling problem, and the honest fixes are fewer
concurrent replicas or more disk.

Deployed to home-runner-1; deployed md5 matches the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:58:32 +02:00
enricobuehlerandClaude Fable 5 7fc2775fad chore: the third-party notices catch up with the rebase
audit / bun-audit (sdk) (push) Successful in 20s
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (web) (push) Successful in 21s
audit / pnpm-audit (push) Successful in 9s
audit / docs-site-audit (push) Successful in 1m16s
audit / cargo-audit (push) Successful in 3m51s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 2m54s
ci / web (push) Successful in 3m3s
ci / docs-site (push) Successful in 1m51s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m55s
audit / license-gate (push) Successful in 7m7s
arch / build-publish (push) Failing after 7m56s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m47s
apple / swift (push) Successful in 5m44s
deb / build-publish (push) Failing after 5m39s
ci / rust-arm64 (push) Successful in 10m45s
ci / bench (push) Successful in 6m56s
android / android (push) Failing after 11m37s
decky / build-publish (push) Successful in 27s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 8m57s
deb / build-publish-host (push) Successful in 11m13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m55s
docker / deploy-docs (push) Successful in 59s
flatpak / build-publish (push) Failing after 9m41s
windows-host / package (push) Successful in 13m44s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 22m43s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 4m51s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m39s
release / apple (push) Successful in 27m44s
Regenerated after rebasing the Windows-client series onto main: the union of the bumped
windows-rs family and main''s os-icons vendored marks, with the conflict the generated
file picked up mid-rebase resolved by regeneration — the only honest edit for a file the
generator owns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:34:22 +02:00
enricobuehlerandClaude Fable 5 6edd7c3377 feat(client/windows): the tile menu grows real submenus
The flyout CAN nest at the current reactor rev (the "no submenus" comment predated the
bump), so the per-profile families move into MenuFlyoutSubItems: one "Connect with"
(bare profile names + Default settings as leaves) and one "Pin tiles" (verb-prefixed
leaves). The top level stays a fixed handful whatever the catalog grows to. The backend
wires submenu clicks recursively and reports LEAF text, so the connect-with leaves are
matched as the fall-through arm against the catalog, and the pin leaves keep their
prefix — that prefix is what tells the two families apart in the shared callback.
UIA-verified through the nesting: Pin tiles -> Pin tile: Work persists and the pinned
tile appears in the same gesture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:11 +02:00
enricobuehlerandClaude Fable 5 a9d3ca2fb1 feat(client/windows): the sheets learn to be dismissed, and the profile sheet says Save
Escape closes every overlay — the profile sheet, the host editor, the add-host modal —
and so does a tap on the scrim. The scrim-tap needed one trick: WinUI bubbles `Tapped`
out of the card into the scrim (reactor cannot mark it handled), so the card raises a
shared flag first and the scrim's handler swallows exactly that tap; only a genuine
outside tap dismisses. The profile sheet's dismiss button reads Save (with the Save
glyph) instead of Close — every field commits as you type, so Save is the promise the
button already kept, and a sheet full of edits wants a verb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:11 +02:00
enricobuehlerandClaude Fable 5 9614e1a92e fix(client/windows): the switcher becomes what it pretended to be — one native control
The fused combo-plus-pencil was three controls squeezed into a wrapper, and hover gave the
trick away (each kept its own radius and hover fill; the pencil clipped). The honest
verdict: WinUI can build a real input group — CornerRadius is per-corner on every control —
but windows-reactor exposes only a uniform radius on Border, so the fake was never going
to hold. The native answer was next to it all along: the switcher is now ONE
DropDownButton — its label is the scope in play, its menu holds Default settings, the
profiles, "New profile…" and "Edit …" — with one hover state and no seams, and the pencil
folded into the menu. Retired with the ComboBox: the items/selected_index remount hazard
(a button label is one plain prop) and the scope-sentinel id.

UIA-verified: menu -> Work applies the scope (the section repaints, markers included),
menu -> Edit opens the sheet, Close closes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:11 +02:00
enricobuehlerandClaude Fable 5 6703be377f feat(client/windows): pinning moves to the tile menu, and the chrome quiets down
Pin/unpin lives in the tile's "…" menu now (review decision, reversing the editor-owns-it
rule): one flat prefix-matched entry per profile — "Pin tile: X" / "Unpin tile: X" —
beside Copy link and Create shortcut, the other tile-shaped actions. The write is paired
with a new `hosts_rev` bump (the hosts-page mirror of `settings_rev`), so the pinned tile
appears — or vanishes — in the same gesture instead of on the next discovery tick.
UIA-verified: menu -> Pin tile: Work -> the pin is in the store and the second tile is on
the grid immediately.

The rest is the review list:
* Both sheets (host editor, profile) put their content in a scroll_view — a window shorter
  than the card scrolls it instead of clipping the bottom controls.
* The home header keeps ONE labelled action: Add host, in accent. Console/Shortcuts/
  Settings drop to icons with tooltips — four written-out buttons read as four competing
  calls to action, and icon-only removes the compact-width special case too.
* The profile switcher becomes one combined element: combo + pencil (icon only, the label
  is gone) inside a shared control-look wrapper — stock 4-epx radius, CardStroke outline,
  the pencil a borderless segment. In the defaults scope the bare combo stands alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:11 +02:00
enricobuehlerandClaude Fable 5 8783f11e90 fix(client/windows): pinning works where you can see it, and the rows learn one skeleton
"Can't pin hosts with a certain profile" — live diagnosis found the pin switches WORKED
but sat below the fold: the host editor rendered as an in-grid tile, its grid cell parked
mid-page, and on an ordinary window the pin section's visible rect was a 9-px sliver a
mouse can't hit (UIA's pattern-toggle, which bypasses hit-testing, persisted fine — that
is what isolated it). The editor is now a centred sheet — scrim + card, titled with the
host, Save/Cancel right-aligned — in a stable overlay slot beside the add modal and the
forget dialog. Verified end-to-end with a real mouse click: toggle flips, the pin lands in
client-known-hosts.json, and the pinned tile is on the grid after Save. Pin toggles also
log now — this hunt was blind until they did.

The settings rows learn one skeleton, label / (marker) / input / caption, per review:
* The Overridden capsule ("Overridden │ Reset", one tinted pill, whole-pill tap target)
  sits BETWEEN the row's label and its input, left-aligned — not trailing controls of
  wildly different widths, not mixed into the caption.
* Labels move out of the widgets (`.header` is gone from the row builders) into the row
  itself — a widget-embedded header allows nothing between label and box, and uniform
  6-epx row spacing needs the row to own all four lines.
* The scope bar's combo and Edit button share an explicit 36-epx height, and the bar shows
  the active profile's colour as a 12-epx chip beside the combo (dropdown items are plain
  strings in this toolkit, so the chip cannot ride inside the list).
* The sheet's colour swatches size the disc itself, not its child — they rendered as
  squashed ovals under the sheet's layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:10 +02:00
enricobuehlerandClaude Fable 5 ba7667ee2b fix(client/windows): dialogs stop panicking the shell, and the chrome stops shouting
Deleting a profile reliably killed the app with E_BOUNDS ("Daten außerhalb des gültigen
Bereichs"): a ContentDialog is a PHANTOM child in the reactor backend — tracked logically,
never attached to the panel — and the reconciler unmounts a child before removing it, so
by the time `remove_child` runs the dialog's handle is gone, the backend no longer
recognises it as phantom, and it RemoveAt()s a visual child that never existed. (The third
upstream windows-reactor bug this client documents.) Both confirmation dialogs — delete
profile, forget host — are now ALWAYS MOUNTED with `is_open` doing the arming, and both
live in STABLE overlay slots (settings: [nav, sheet slot, dialog]; hosts: [page, add-modal
slot, dialog], each closed slot a same-kind background-less Border) so no pass ever
removes or repositions a dialog. UIA-verified: create profile -> delete -> confirm, the
shell survives.

The rest is the review feedback:
* A group with no fields renders NOTHING — Decoding and Library showed a heading over an
  empty card in profile scope (device facts, never per profile).
* The override marker leaves the caption: a small "Overridden" chip and the Reset button
  sit beside the control, bottom-aligned; the description below stays a description,
  identical in both states.
* Host tiles stop wearing three badges: Paired is the resting state and earns no chip
  (a chip is for a decision — Trusted/PIN/Open), and the bound profile becomes a small
  dot in its accent colour plus the name in caption text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:10 +02:00
enricobuehlerandClaude Fable 5 7bbee0eeb6 feat(client/windows): the profile selector settles above the settings surface
Out of the NavigationView for good. The pane footer clipped in the compact rail, the
clip-aware monogram variant read as broken, and reactor offers no pane-opened event to
adapt on — so the switcher is now a slim bar riding an Auto grid row above the nav: an
"Editing" label, the scope combo, and Edit profile… (with its glyph), each vertically
centred against the combo and sharing the content column's 24/28 page margins. Visible
from every section at every width, in no one's way, and the nav below keeps its Star row
so the surface still fills the window. The sheet drops its embedded scope combo — the bar
owns the choice now — and opens only with a profile in scope.

UIA-verified live: scope switch from the bar repaints in place (marker 5 -> 6), the sheet
opens from the bar and closes, Default scope restores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:50 +02:00
enricobuehlerandClaude Fable 5 69743e3a09 feat(client/windows): the profile surfaces get their icons
Reset carries Undo, Edit profile carries Edit, Duplicate/Delete/Close carry Copy, Delete
and Accept — the icon support the bumped reactor added (#4736), applied where a glyph
disambiguates faster than a word. The rest of the shell already had its icons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:50 +02:00
enricobuehlerandClaude Fable 5 e8eb803a3f feat(client/windows): 4:4:4 reaches Windows, and the Overridden marker stops moving the page
The GTK client grew its "Full chroma (4:4:4)" switch in the same commit that fixed its
override marker; Windows got neither until now. The row lands in the Quality group under
HDR with the GTK wording verbatim (same setting, same constraints), a full overridable
profile row — the core overlay and the session binary already spoke `enable_444`, so the
shell was the only gap. OverrideFlags grows the matching flag, and a unit test now pins
the whole overlay -> row-flag mapping (tri-state resolution included): a field that
records without marking its row is the original Overridden-row bug wearing a new face.

And the marker obeys the GTK rule learned the hard way there: nothing that appears on an
edit may move what was edited. Both states of a profile-scope row reserve the same
caption-line height, so the marker + Reset materialise in place instead of shoving every
row below them down by a button's height.

Scope-blind audit (the bitrate-box failure class): clean. Decoder and GPU are hidden in
profile scope as device facts (GTK parity), the forwarded controller stays gated, and
every visible profile row commits through `commit`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:50 +02:00
enricobuehlerandClaude Fable 5 09d1691af4 fix(client/windows): the switcher footer survives the pane clip instead of guessing at it
Binding the footer's form to the WINDOW width was wrong twice over: the pane's open state
belongs to the user (the hamburger), so a wide window with a closed pane still clipped the
combo, and a narrow window with the pane opened still showed the compact disc. Reactor
exposes no pane-opened/closed event — so the footer now needs no pane state at all: its
LEADING 48 epx is the scope's monogram disc (profile accent when set) and the combo + Edit
button sit after it, so the compact rail's clip line falls exactly between them. Closed
pane → a clean disc (which opens the profile sheet); open pane → disc + combo. The same
trick NavigationViewItems use, at every width and in every mode — the display mode goes
back to Auto, the width threshold and the root window-size read are gone.

The sheet is now self-sufficient (title "Profiles"): it always carries the scope combo —
whether the pane's own combo is visible depends on state this page cannot observe — plus
the name/colour/duplicate/delete rows whenever a profile is in scope, so the disc is a
complete profile entry point from the bare rail.

UIA-verified: scope pick from the pane combo, Overridden marker 5 -> 6 in place, the
sheet opens from Edit profile… and closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:49 +02:00
enricobuehlerandClaude Fable 5 4b5bca93a0 fix(client/windows): the scope switcher stays in the nav at every width
The width-adaptive fallback moved the switcher OUT of the pane on narrow windows, and in
the pane it could still clip — both read as broken. Now the nav is the switcher's only
home: `Left` mode (always-expanded 280 pane, combo sized to fit) while the content column
gets a workable width, and below that a `LeftCompact` rail whose footer swaps to a fitting
compact form — a 40-square monogram disc for the scope (profile accent when set) instead
of a clipped ComboBox. The disc opens the profile sheet, which in compact mode carries the
scope combo too (keyed per scope against the items/selected_index diff hazard), so
switching, creating, and editing profiles all stay reachable from the rail. The inline
content-column fallback is gone.

UIA-verified on the expanded path: scope pick from the pane, Overridden marker 5 -> 6 in
place, sheet opens and closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:49 +02:00
enricobuehlerandClaude Fable 5 2409ec9a4b docs(client/windows): the harness passes where the real backend fails, and the note says so
Case 3 of the characterization suite (a sync use_state write from a backend-fired handler
re-renders) is true of the ENGINE and was taken as license to de-hoist event-driven page
state — and the live app immediately disproved it: with settings componentized and its
scope local, a real ComboBox pick changed nothing on screen (UIA-verified, no repaint).
The real WinUI backend's handler wiring still never pumps the pass the harness pumps.

The de-hoist is reverted one commit back; this one makes the record honest. The module
discipline note now separates the three tiers of evidence — engine rules (harness-pinned),
real-backend behaviour (live-verified, hoisting stays), and the tween-keyed corollary —
and case 3's comment warns it must not be read as de-hoist permission without a live UIA
check. The upstream gap worth reporting grows to two: the AsyncSetState HostId registry
miss, and the backend event wiring that bypasses the render pump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:49 +02:00
enricobuehler 27039e04ff Revert "refactor(client/windows): page-only state climbs back down from root, on the measured rules"
This reverts commit 427f99a06647fd99e5c72d18cb7a710d8a7ac885.
2026-07-29 18:31:49 +02:00
enricobuehlerandClaude Fable 5 69c13b5aae fix(client/windows): the settings pane stops auto-collapsing under the default window
WinUI Auto mode collapses the NavigationView pane below 1008 epx — narrower than the
app's own 1000-wide default window, so the scope switcher kept getting booted out of the
nav into its inline fallback. The page now forces the mode: Left (always expanded, at a
tighter 250 pane length) down to 720 epx, LeftMinimal below that — the switcher lives in
the pane wherever the content column still gets a workable width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:48 +02:00
enricobuehlerandClaude Fable 5 6ab2ee4819 refactor(client/windows): page-only state climbs back down from root, on the measured rules
The root component had absorbed every page's UI state because of re-render
folklore; the characterization suite (tests/reactor_semantics.rs) replaced the
folklore with measured rules at the current windows-reactor pin: a child's sync
use_state re-renders its owner reliably, including from backend-wired event
handlers (MenuFlyout clicks, pointer enter/exit, ComboBox selection) and under
element-equal wrappers. Only a background thread's AsyncSetState write is still
dropped. So user-event-driven page state moves into the pages that own it:

* hosts_page: forget, rename and hover become local sync use_state; the
  HostsProps fields, their PartialEq arms and the root plumbing are gone.
* settings_page: converted from a hook-free 14-parameter fn into a real
  component(...) with a SettingsProps (data-only PartialEq, the HostsProps
  pattern). Scope, the delete confirmation, the edit-modal flag and the repaint
  revision are its own sync use_state; commit() and the row helpers take the
  sync setter now.

What deliberately stays rooted, each with its reason in place: show_add and the
settings section drive root tween workers (a thread writing root async state
can only be started by the owner of the trigger), the tween progress values are
those workers' output, and everything thread-fed (discovery, HUD, probes,
pads, deep links, speed, library) keeps the async-drop rule. window size stays
a root read so resize re-renders the tree.

One deliberate behaviour shift: page-local state now resets when its page
unmounts, so re-entering Settings lands on the Default settings scope (the
section still persists, being root state).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:48 +02:00
enricobuehlerandClaude Fable 5 9907ce6229 test(client/windows): the re-render rules become measured, and the switcher survives a collapsed pane
Adopts windows-reactor's own headless harness (the `test` feature + the upstream repo's
`test_reactor`/RecordingBackend crate, same pinned rev) and pins the three semantics this
client's state placement depends on, in tests/reactor_semantics.rs:

* A child's sync `use_state` re-renders it through element-equal wrappers — the pre-bump
  reconciler pruned at the wrapper; fixed upstream (`nested_state_rerender.rs`).
* A sync write from a backend-fired event handler re-renders — the "MenuFlyout handlers
  bypass the flush" claim does not hold at the engine level on this rev.
* A child-owned `AsyncSetState` write still does NOT re-render its owner — newly diagnosed:
  every component RenderCx draws a fresh HostId but rerender callbacks are registered only
  for the root, so the child's request is a registry miss, silently dropped; the value
  surfaces on the next unrelated pass. The test asserts the broken behaviour on purpose, so
  the bump that fixes it upstream turns the test red and the discipline can be relaxed.
  (Upstream's own async test installs the guard for the very cx it tests and cannot see
  this; worth reporting with this repro.)

The stale discipline note in app/mod.rs — written against the June rev — is replaced by the
measured rules, each citing its test. Net: per-screen EVENT-driven UI state may live in the
screen's own component; THREAD-driven state stays hoisted at root, now for a proven reason.

Riding along (field report from the live review): the scope switcher no longer clips when
the NavigationView collapses. WinUI's Auto mode shrinks the pane below 1008 epx and CLIPS
pane-footer content to the rail; reactor exposes no pane-opened event, so root now reads
the window size and the switcher follows the same threshold — expanded pane → pane footer,
collapsed → a slim row leading the content column (an Auto/Star grid row, not a vstack,
so the scroll viewport survives).

Test builds use their own CARGO_TARGET_DIR by convention: windows-reactor-setup stages
resources.pri next to the test binary and a RUNNING dev client holds that file mapped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:48 +02:00
enricobuehlerandClaude Fable 5 1c1b81133c fix(client/windows): the settings surface fills the window again
The redesign left the page rooted in a vstack, and a StackPanel hands its child the
DESIRED height — the NavigationView was clipped in a short window and floated in a tall
one. The nav, the Edit-profile scrim and the delete ContentDialog now share one grid, so
every layer stretches with the window (the dialog is its own WinUI layer and only needs
to be in the tree).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:48 +02:00
enricobuehlerandClaude Fable 5 6b956a2bf4 fix(client/windows): the Overridden marker appears on edit, and profile chrome leaves the page
Committing a profile-scope edit wrote the catalog and nothing else — no reactor-observable
state changed, so no render pass ran, OverrideFlags was never recomputed, and the
"Overridden here" row only surfaced after some unrelated navigation. The mirror of the
Linux fix ("the override marker appears on touch"): `commit` now ends every save with a
`settings_rev` bump, threaded through `setting_combo`/`setting_toggle`, so the marker lands
in the same pass that recorded the override. The bitrate box goes through `commit` too —
it wrote the GLOBAL settings regardless of scope, which corrupted the defaults from inside
a profile and could never mark its row.

The profile chrome moves out of the content column while its wiring is open. The scope
switcher sits in the NavigationView's pane footer (`pane_footer`, new in the bumped
reactor) where it reads as navigation chrome — which layer am I editing — instead of a bar
of controls above every section. Rename, colour, duplicate and delete hide behind an
"Edit profile…" modal (the Add-host scrim-and-card pattern; ContentDialog still fits no
text box), and "New profile…" lands straight in that modal to be named. The footer combo
is keyed by scope + name list so a rename or delete remounts it past the documented
items/selected_index diff hazard; renames repaint the dropdown on modal Close, not per
keystroke.

The absorb-level semantics (record the touched field against the effective snapshot; a
value equal to the global is still a pin) were already covered by pf-client-core's
`absorb_records_the_touched_field_only`; render-level tests wait for the reactor test
harness. Verified live via UIA on the running shell: with the "Game" profile in scope,
changing Render scale grew the marker count 5 -> 6 in place with no navigation, the
override landed in client-profiles.json, the global settings file stayed byte-identical,
and the Edit modal opens and closes from the pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:47 +02:00
enricobuehlerandClaude Fable 5 35c61fee64 chore(client): the windows-rs pin moves a month forward, onto the SDK-metadata bindings
The July 2026 windows-rs brings a reconciler keyed-child-order fix (#4728), widget
validation (#4727), a DPI collision fix (#4751), icon elements (#4736), multi-window
support (#4730) and scroll virtualization (#4710) — the re-render fixes the Windows
client has been working around at the architecture level. All three pinned deps
(windows-reactor, windows, windows-reactor-setup) move together so windows-core
stays unified across the swap-chain hand-off, and pf-client-core moves with them.

The bulk of the diff is #4689: windows/windows-sys now generate straight from the
Windows SDK, so the `Win32_*` namespace features became one feature per SDK header
(winuser, dxgi, d3d11, …), the PascalCase namespace modules became header-named
modules, struct-returning COM methods take explicit out-params and return HRESULT,
Win32 functions return their raw BOOL/HANDLE instead of Result, and flag constants
are plain integers. Both crates' Win32 code is rewritten to that shape; behaviour
is unchanged on every path.

Riding along, all already stale before the bump: the README and the three Windows
workflows stop claiming windows-reactor's build.rs needs CARGO_WORKSPACE_DIR (that
build.rs no longer exists — staging moved to windows-reactor-setup via OUT_DIR);
the README layout section stops describing modules that moved into the session
binary long ago and gains the manual smoke checklist; the notices generator learns
the SPDX for crates that ship license files without a `license` field, which turns
windows-reactor-setup's UNKNOWN into MIT OR Apache-2.0; and the crate records its
real rust-version (1.96) instead of inheriting the workspace's 1.82.

Verified: cargo check/clippy/fmt clean on punktfunk-client-windows, pf-client-core
and punktfunk-client-session; both bins build; --discover finds the LAN hosts; the
GUI shell comes up (WinAppSDK bootstrap intact under the new reactor-setup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:47 +02:00
enricobuehlerandClaude Fable 5 ddd0e5cc5d fix(vdisplay/kwin): primary is a PRIORITY, and the log now reads the answer back
audit / bun-audit (plugin-kit) (push) Successful in 19s
audit / bun-audit (sdk) (push) Successful in 17s
audit / pnpm-audit (push) Successful in 10s
audit / docs-site-audit (push) Successful in 28s
audit / bun-audit (web) (push) Successful in 30s
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 2m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m35s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m50s
ci / bench (push) Successful in 6m47s
deb / build-publish (push) Failing after 6m36s
audit / license-gate (push) Successful in 7m54s
decky / build-publish (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 55s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 28s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / rust-arm64 (push) Successful in 10m48s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m55s
apple / swift (push) Successful in 4m50s
android / android (push) Successful in 12m33s
deb / build-publish-client-arm64 (push) Failing after 5m44s
deb / build-publish-host (push) Failing after 10m5s
ci / rust (push) Failing after 13m19s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m4s
docker / deploy-docs (push) Successful in 29s
docker / build-push-arm64cross (push) Successful in 37s
audit / cargo-audit (push) Successful in 2m46s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m2s
flatpak / build-publish (push) Failing after 8m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 12m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 12m18s
arch / build-publish (push) Successful in 15m43s
apple / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 18m42s
windows-host / package (push) Canceled after 13m31s
windows-host / winget-source (push) Canceled after 0s
A field report (CachyOS, KWin 6.7.3, Plasma): topology primary streams
the new virtual display but the desktop stays on the physical — while
the host logs primary_taken=true on every apply. Both halves were real:

* KWin's handler for kde_output_configuration_v2.set_primary_output is
  literally '// intentionally ignored' (verified in the 6.7.3 source).
  The output order is driven by per-output set_priority (management >= 3;
  we bind up to v22 and never called it). Exclusive only ever LOOKED
  right because disabling every other output leaves KWin nothing else to
  promote. Now: ours gets priority 1 and every other enabled output is
  renumbered uniquely behind it, exactly what kscreen-doctor does — the
  reporter proved that path applies and sticks on their box.
  set_primary_output is still sent for pre-v3 compositors.

* primary_taken echoed the REQUEST. The log now waits one sync barrier
  for KWin's post-apply priority events and reports primary_verified
  from the read-back — a request the compositor ignores can never ship
  a green log again; the mismatch is a WARN naming the symptom.

First-slot-wins refinement the working priorities made necessary: a
SAME-NAMED output is this slot's own predecessor mid-supersede (mode
switches create the replacement before dropping the old), not a sibling
whose primary must be respected — deferring to it handed primary to
whatever KWin promoted when the predecessor vanished a moment later.

On-glass (nested KWin 6.7.3): fresh create, high-refresh custom-mode
create, and the mode-switch supersede all read back verified=true;
exclusive still disables + restores the other output in-process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:59:24 +02:00
enricobuehlerandClaude Fable 5 a8744b98d1 feat(devtools): reproduce the capture-model cursor path without a real client
punktfunk-probe grows --cursor-capture: advertise CLIENT_CAP_CURSOR,
flip the channel to the capture model (CursorRenderMode client_draws=
false), and wiggle RELATIVE pointer motion for the whole dump — decode
the .h265 and the host-composited pointer must be in the pixels. Plus
--codec pyrowave (advertised only on request so the dump format of
existing recipes never changes).

tools/cursor-probe stands up the capture side alone: virtual output with
the out-of-band cursor, production PipeWire capturer, production
injector, absolute then relative motion — and reports whether
SPA_META_Cursor ever yields an overlay. It is how Mutter's
pointer-in-stream metadata gate was isolated on-glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:59:04 +02:00
enricobuehlerandClaude Fable 5 751fbd6c47 fix(host/linux): park the seat pointer on the streamed surface — a capture-mode client cannot
ROOT CAUSE of the GNOME capture-mode cursorless stream (and the same
latent hole on KWin, whose screencast also gates cursor data on
includesCursor): a virtual output is created fresh per session while the
seat pointer stays wherever it was — usually a physical monitor. A
capture-model (pointer-lock) client sends only RELATIVE deltas, so
nothing ever moves the pointer INTO the streamed output: input lands on
the wrong monitor, Mutter suppresses SPA_META_Cursor entirely
(should_cursor_metadata_be_set: visible AND in-stream), and both the
embedded and the encoder-blend composite models stream cursorless.

The stream loop now parks the pointer at the streamed surface's centre
through the session's own input pipeline (capability routing, region
ladder, anchor — the path client events take): armed per (re)built
display and by the capture-model flip, retried on a schedule because the
first park of a session can land on a still-cold EIS connection (devices
not resumed — the injector drops it; observed on-glass), and kept trying
while a capture-model session still has no live overlay. A desktop-model
client overrides the park with its first absolute move.

Verified end-to-end on GNOME 50.3/Mutter (RTX 5070 Ti): the reference
client in capture mode now shows the host-composited cursor moving in
the decoded H.265 dump, and the PyroWave session hands the overlay to
the wavelet CSC blend. One-shot per-session/per-stream breadcrumbs
(first meta region, first bitmap, first overlay handed to the blend /
still-cursorless) make the next field triage a grep instead of a rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:59:03 +02:00
enricobuehlerandClaude Fable 5 39aa0c57ce fix(inject/libei): a scaled output's region is the streamed mode in logical pixels — match it
A display scale s shrinks an output's EI region to logical pixels (Mutter
advertises 853x533 for a 1280x800 output at 1.5), so the exact-size rung
missed every scaled output and absolute input fell through to
regions.first() — the wrong monitor whenever another region sorts first
(on-glass: a lingering sibling virtual display). New rung between exact
and first: one consistent scale factor (1..=4, fractional included) must
map the region onto the mode on both axes, with per-axis rounding slack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:59:03 +02:00
enricobuehlerandClaude Fable 5 73e742d877 fix(client/session): the declared GTK deps reach the lockfile
audit / bun-audit (web) (push) Successful in 17s
audit / docs-site-audit (push) Successful in 18s
audit / pnpm-audit (push) Successful in 11s
audit / bun-audit (plugin-kit) (push) Successful in 47s
apple / swift (push) Successful in 1m39s
audit / bun-audit (sdk) (push) Successful in 1m32s
android / android (push) Canceled after 3m51s
audit / cargo-audit (push) Successful in 3m46s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m58s
audit / license-gate (push) Canceled after 3m23s
ci / bench (push) Canceled after 7s
ci / rust (push) Canceled after 3m13s
ci / rust-arm64 (push) Canceled after 3m6s
ci / docs-site (push) Canceled after 11s
ci / web (push) Canceled after 2m12s
deb / build-publish (push) Canceled after 5s
deb / build-publish-host (push) Canceled after 3s
deb / build-publish-client-arm64 (push) Canceled after 2s
decky / build-publish (push) Canceled after 2s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 3s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 2s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 5s
release / apple (push) Canceled after 2m51s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s
windows-host / package (push) Canceled after 4m6s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
61bdf11e declared gtk4/libadwaita/relm4/async-channel on the session shell but
the corresponding Cargo.lock entries never rode along, and CI's `--locked`
clippy does not degrade to a re-resolve — it fails outright. One resolve,
no version changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 67ab64ed06 feat(web): the Host page names — and marks — the operating system
The Identity card gains an "Operating system" row: the pretty name as text,
the raw identity chain as its tooltip, and a leading OsIcon resolved by the
same most-specific-first walk as the clients. lucide deliberately ships no
brand marks, so os-icon.tsx vendors the ten as inline currentColor SVGs
(the brand-mark precedent, the plugin-registry pattern), falling back to a
generic monitor for a chain nothing recognizes. Row grows optional icon/title
props rather than a fork; stories cover Bazzite, Windows and the
unknown-distro degradation; labels land in both message files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 2ca3f729fc feat(client/decky): host rows wear the OS mark for free
react-icons already ships every brand mark, so this client costs nothing:
the avahi parser and the saved-hosts feed surface the new `os` chain
(optional on SavedHost — the installed flatpak client may predate the field),
the merge model threads it through with the live advert preferred, and the
row label leads with the resolved mark via the same most-specific-first walk
as every other client. A payload without `os` renders exactly as today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 63224cd31d feat(client/android): the eighth field carries the OS, and cards wear it
The JNI discovery record appends `os` as its eighth ␟-field (append-only —
the Kotlin parser's arity guard already tolerates both old and new records,
now pinned by tests in both directions), sanitized on the Kotlin side by the
mirrored chain grammar next to the shared `osIconTokens` walk. `KnownHost`
persists it additively (optString — no schema bump, migration passes it
through) with `learnOs` beside `learnMac`, learned on the same discovery tick.

Compose ships no brand icons, so OsIcons.kt vendors the ten marks as raw SVG
path strings built into ImageVectors via PathParser (lazy, cached) — they tint
with the Material theme like any Icon. The host card's address line leads with
the mark, live advert preferred over the stored chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 88c8688b47 feat(client/apple): host cards wear the OS mark, and the store learns it
`DiscoveredHost` reads the new `os=` TXT (sanitized in PunktfunkShared's
OsChain.swift — the pure grammar + walk, mirrored from pf-client-core so every
platform resolves identically, and dependency-free so the widget could use it);
`StoredHost` appends optional `osChain` per the frozen app↔widget contract
(optional + appended last; legacy JSON still decodes, pinned by the round-trip
tests), and `HostStore.updateOsChain` learns it beside the MACs.

The art rides PunktfunkKit's proven resource path (the fonts precedent): ten
template vector imagesets in Resources/OsIcons.xcassets, compiled by the Xcode
build into the Kit bundle's Assets.car (verified: all ten resolve in the built
app) and tinting via foregroundStyle like an SF Symbol. Saved and discovered
cards lead their status line with the mark; a host that advertises nothing
renders exactly as before. GamepadHome tile + widget marks are follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 944c03dd32 feat(client): the desktop clients wear the host's OS mark
The client half of the host's new `os=` advert, shared once in pf-client-core:
`sanitize_os` (mDNS is unauthenticated input — lowercase `[a-z0-9._-]` tokens,
capped) and `os_icon_tokens`, the most-specific-first walk with the brand
aliases (`macos`→apple, `steamos`→steam) every platform resolves through.
`DiscoveredHost` carries the chain, `KnownHost` persists it (`serde(default)`,
elided when empty — older stores load unchanged and older clients read back
exactly what they wrote), `upsert` moves it only when carried, and `learn_os`
mirrors `learn_mac` — no-op, no disk write when unchanged — so the mark
survives the host going to sleep.

GTK shells: the card's status row leads with a recolorable symbolic glyph.
That needed real embedded assets — the shells had none — so `data/` gains the
ten `pf-os-*-symbolic` SVGs compiled into a gresource (new build.rs,
glib-build-tools) and registered on the icon theme at startup; the Adwaita
theme then tints them like every other status glyph.

WinUI shell: reactor renders raster-from-URI only, so the embedded mid-gray
PNGs (legible on both themes) materialize once into
%LOCALAPPDATA%\punktfunk\os-icons\ — the library's poster-art pattern — and
the tile's status row leads with a 16px image.

The couch UI plumbs `HostRow.os` (live advert preferred, else the store) for a
Skia glyph that is a declared follow-up; `--list-hosts` / `hosts --json` emit
the stored chain so the Decky plugin can read it. A host that advertises no
`os` renders everywhere exactly as it did before the field existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 4c2a6dd091 feat(host,api): the host knows what OS it runs, and says so
Clients want to show which OS/distro a host runs. The host now detects it once
per process (new osinfo module: compile-time on Windows/macOS, os-release on
Linux) and advertises an icon-friendly specificity chain, generic → specific —
`windows`, `macos`, `linux[/<family>][/<id>]`, e.g. `linux/fedora/bazzite`,
`linux/arch/steamos`. A client walks the chain most-specific-first and shows the
first token it has art for, so an unknown distro degrades to its family's mark
and finally to plain Tux — the host emits the full chain precisely so clients
need zero distro→parent knowledge. The middle token is the first recognized
ID_LIKE ancestor (the spec orders them most-similar-first); the leaf is ID
verbatim, sanitized to TXT-safe `[a-z0-9._-]` because it feeds a DNS record.

Two carriers, both additive: a new advisory mDNS `os=` TXT key (same trust
posture as `mac` — unauthenticated, a wrong value only draws a wrong icon), and
`HostInfo.os` + `HostInfo.os_name` on the mgmt API (`os_name` is the os-release
PRETTY_NAME, REST-only so the TXT stays small). GameStream serverinfo and the
QUIC Welcome are untouched. api/openapi.json regenerated (drift test green on
Linux) and the TS SDK gen refreshed from it — which also catches the committed
gen up with earlier spec changes it had missed (monitors, native_paired_clients,
encoder_backend).

assets/os-icons/ gains the ten master SVG marks every client derives its
per-platform art from (Font Awesome Free brands CC BY 4.0 + Simple Icons CC0 —
provenance in its README), with attribution folded into THIRD-PARTY-NOTICES.txt
via the generator (regenerating also catches the stale crate manifest up with
the current lockfile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Opus 5 e44baf6768 fix(client/android): a decoder fed nothing it can decode must ask for a new anchor
android / android (push) Canceled after 33s
apple / swift (push) Canceled after 27s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 34s
ci / rust (push) Canceled after 8s
ci / rust-arm64 (push) Canceled after 3s
ci / web (push) Canceled after 2s
ci / docs-site (push) Canceled after 3s
ci / bench (push) Canceled after 2s
deb / build-publish (push) Canceled after 6s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6s
An Android TV box reported a black screen with a perfectly healthy HUD: AUs
arriving at 40 fps, ~312 bytes each. That size is all-P-frames — no IDR anywhere
in the window, and nothing asking for one. The stats being readable is the rest
of the story: the overlay is a layer over the SurfaceView in the same window, so
the panel was fine and the surface simply never received a frame.

The decode thread only starts at `surfaceCreated`, so a slow box can be handed
the stream mid-GOP. A hardware decoder does not error on references it never
had; it emits nothing at all. Under infinite GOP the host sends no further IDR
unless asked, and neither Android loop ever asked: every recovery trigger they
have keys off a drop, a gap or a decode error, and a decoder that quietly
produces nothing trips none of them. The session stayed black for its whole life.

The shared gate has this case (`on_no_output`, which pf-client-core and the Apple
client both feed) but its per-AU streak counts one-in/one-out decodes, and
MediaCodec is pipelined — "this AU produced no output" is not something these
loops can observe. A wall-clock silence window is the same signal in the shape
Android can measure: fed for 500 ms with nothing coming back arms the freeze and
requests a re-anchor keyframe, and the gate's deadline keeps re-asking until one
lands. 500 ms so it can never fire on a decoder that is merely slow to spin up.

Also log the first presented frame. The periodic tally starts at 300 rendered
frames, which is no help whatsoever on a session that renders none — its absence
is what separates "never reached glass" from "reached glass and looked wrong".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:57:13 +02:00
enricobuehlerandClaude Opus 5 1862010003 fix(client/android): a settings edit belongs to the scope the chips show
Change a setting on the defaults, switch to a profile, change the same row: the
globals moved again and the profile recorded nothing. Switch back, and the next
edit went into the profile — which reads as "the default settings can't be
changed any more".

`update`/`resetField` reached the rows as `::update` — callable references, and
two of those compare equal however different the scope they closed over. Compose
saw an unchanged callback and skipped the whole detail page on a scope switch
that moved no value on screen, which is the ordinary case: a profile inherits the
globals until it overrides something. The row went on calling the reference it
was first handed, one scope behind for the rest of the session.

Resolve the scope from the live state at the edit instead of closing over it, so
the write follows the chips whether or not the page recomposed. `key(active?.id)`
around the detail page covers the other half — a row's own `remember` is
per-scope state too, and "Custom…" picked while editing a profile has no business
still being picked over on the defaults.

The model tests can't see this: it takes the real Compose runtime to skip a
composable. SettingsScopeTest drives the screen itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:57:13 +02:00
enricobuehlerandClaude Opus 5 5f29db3975 fix(ci/runner): grow the runner disk, and simplify the burst condition
ci / rust-arm64 (push) Canceled after 42s
android / android (push) Canceled after 39s
apple / swift (push) Canceled after 41s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 44s
ci / rust (push) Canceled after 44s
ci / web (push) Canceled after 41s
ci / docs-site (push) Canceled after 41s
ci / bench (push) Canceled after 40s
deb / build-publish (push) Canceled after 7s
deb / build-publish-host (push) Canceled after 1s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 1m17s
windows-host / package (push) Canceled after 2m16s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The capacity lever, taken: home-runner-1's LXC rootfs went 123 G -> 175 G
(`pct resize 116 rootfs +50G` on home-node-1, online, no downtime, thin pool had
~607 G spare). Free space went 77 G -> 124 G, which is the part that actually
gives three concurrent Rust builds room; the 10-minute prune is now a backstop
rather than the only thing standing between a push-storm and ENOSPC.

MIN_FREE_GB stays at 45. It is deliberately an absolute floor, not a percentage:
what three concurrent target/ dirs need does not change when the disk is resized,
but a percentage threshold silently does — 80% meant ~25 G free before and ~35 G
now. That is exactly why the percentage alone was the wrong instrument.

Also replaces the multi-line `{ …; } || { …; }` burst condition with two flat
tests into a flag. Same semantics, and shellcheck parses it — the brace-group
form across a line break did not survive an edit to the comment above it.
Truth-tabled: quiet at 25%/124G, fires on either signal alone, and stays quiet
when df returns nothing rather than treating an empty reading as pressure.

Deployed to home-runner-1; deployed md5 matches the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:19:07 +02:00
enricobuehlerandClaude Opus 5 7fc387bcbf fix(ci/runner): the disk guard had been dead code, and polled too slowly to matter
arch has been failing with `as: BFD (GNU Binutils) 2.47 assertion fail`, which
reads like a toolchain regression and is not one. The line above it is `can't
write 10 bytes to section .text._ZN6Vulkan...: 'No space left on device'` — the
assembler handling ENOSPC badly. Both recent arch failures are the runner filling
its disk, and by the time anyone looks, df reports 37% used.

Three things were wrong with the hygiene that was supposed to prevent this.

The cache cap and the burst-clear were dead code. They looked up the runner as
`docker ps -f name=gitea-runner-runner`, which matches zero containers now that
the replicas are `gitea-runner-fleet-runner-N-1`, so $RUNNER was always empty and
both branches were skipped. The store also moved: the fleet runs a standalone
cache-server bind-mounting a HOST directory, so no docker exec is needed at all.

The routine prune reclaimed nothing. `--filter until=6h` on a runner that rebuilds
its CI images every push means every image is younger than the window — measured:
0B reclaimed while docker system df reported 22.76 GB reclaimable. Now until=2h.

The burst guard never fired. It polled every 30 minutes for >=80% used, but three
concurrent Rust builds fill the disk and drain it again well inside that window,
so the poll kept landing on a healthy df. Now every 10 minutes, and it triggers on
a free-space FLOOR too — 80% of 123 G still leaves only ~25 G, which three jobs
swallow before the next poll.

Deployed to home-runner-1 and exercised: shellcheck clean, timer active on the new
interval, one run reclaimed 551 MB. Honest limit: this improves the odds, it does
not fix the cause. The remaining 22 GB of idle images are the fedora-rpm bases the
next run wants back, so there is no free headroom to reclaim — three replicas
building this workspace share one 123 G disk. The lever is capacity (grow the LXC)
or concurrency (drop to two replicas), and that is a judgement call, not a script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:13:24 +02:00
enricobuehlerandClaude Opus 5 61bdf11e4d fix(client/session): declare the GTK deps the session shell has been using
b0ea1e6b added nine files to clients/session/src/ — app.rs, cli.rs, the four
ui_*.rs, shortcuts.rs, spawn.rs — and did not touch clients/session/Cargo.toml.
The sources `use adw::prelude::*` and `use gtk::{gdk, gio, glib}`; the manifest
declared neither. Every build that compiles this crate has been failing since:
327 errors, all E0433 "cannot find module or crate", taking rpm and deb down with
it. main has been red for both ever since.

Adds a linux-only dependency block — the modules are #[cfg(target_os = "linux")]
in main.rs, and the Windows leg of this crate is a stub that must not pull GTK.
Versions track clients/linux verbatim (gtk4 0.11 "v4_16", libadwaita 0.9 "v1_5",
relm4 0.11 "libadwaita", async-channel 2): the two crates compile the same widget
vocabulary through relm4, where a version skew surfaces as unreadable type
mismatches rather than as a version complaint.

glib/gio/gdk/pango are deliberately NOT declared — the sources reach them as
gtk::glib, gtk::gio, gtk::gdk, gtk::pango, gtk4's own re-exports. Declaring them
separately invites resolving a different version of a sys crate than the one gtk4
links against.

Also makes serde_json non-optional on Linux. It was optional behind the `ui`
feature, but cli.rs's `--json` host listing is a CLI feature, not a console-UI
one — so `--no-default-features`, which this crate's own comments document as a
supported Linux build ("same streaming, stats on stdout only"), did not compile.
That was latent, not part of the CI breakage; found by building it.

Verified on a real GTK4 box (gtk4 4.22.4, libadwaita 1.9.2): cargo check passes
both --no-default-features and default features, the latter being what rpm and
deb build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:13:07 +02:00
enricobuehlerandClaude Opus 5 907985a0b9 docs(packaging/windows): record the driver-signing fingerprint
The stable cert is live and CI is signing with it. Fills in the placeholder in
both docs and adds the one-liner to check an installed driver against it.

Thumbprint 4B8493E7CD565758D335F8F4F05C5A7261A13E02, verified off the .cer a real
windows-host build produced on the runner, not from the key material: RSA 3072,
valid to 2036, extensions 2.5.29.15/37/14 and nothing else. The decisive part is
that the pf-vdisplay and gamepad bundles now carry the SAME thumbprint — the
per-build fallback had each script mint its own 2048-bit cert, so they never
matched. That is what proves the secret reached the build rather than the
fallback quietly running again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:01:42 +02:00
enricobuehler dff61ecea2 fix(client/windows): the scope switcher leaves the page content, and the polish from the GTK round comes across
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m8s
decky / build-publish (push) Successful in 36s
ci / bench (push) Failing after 6m40s
deb / build-publish-client-arm64 (push) Failing after 5m55s
ci / rust-arm64 (push) Failing after 7m3s
arch / build-publish (push) Failing after 7m26s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 36s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 35s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m0s
apple / swift (push) Successful in 5m9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m53s
deb / build-publish-host (push) Successful in 10m36s
docker / build-push-arm64cross (push) Successful in 13s
android / android (push) Successful in 12m36s
docker / deploy-docs (push) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m0s
deb / build-publish (push) Failing after 6m54s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m17s
ci / rust (push) Failing after 8m37s
flatpak / build-publish (push) Failing after 8m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m54s
apple / screenshots (push) Successful in 20m39s
Four things the Linux review found, applied here without waiting to re-find them:

The profile switcher was rendered as the first group INSIDE each section's
content, so on About — a page with barely any rows — it read as an About
setting, and on every other page it read as one more row. It is chrome: which
layer am I editing. It now sits in its own card above the NavigationView,
visible from every section and never mistakable for a setting.

Profiles get their colour here too, from the same eight-swatch palette as GTK,
and the chip on a host tile is tinted with it — a profile that is red on Linux
is red on Windows. An unparsable accent falls back to the neutral chip rather
than being handed to the renderer.

The tile flyout had become a list of everything, with connect/library/speed
buried under list management. Two rules thin it: anything that CONFIGURES the
host moves into the editor — the pinned tiles join the default profile and the
clipboard toggle there, since a pin is a property of the record — and what
remains is grouped by separators, so a glance lands on the right third.

Compiled on .221; the last one-line clippy fix went in after the box dropped.
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 b498b47228 fix(client/linux): the third-party notices were being dropped, not just logged about
Opening About logged a Pango error and, more to the point, LOST the section it was about:
`adw_about_dialog_add_legal_section` takes markup, not plain text, and the notices are
generated from crate metadata — so the first author address in them, `<name@example.com>`,
reads as an unclosed tag ("is not a valid name: @") and Pango refuses the whole blob. A
third-party attribution page that silently renders nothing is a licensing problem wearing
a log line's clothes.

All three licence fields are escaped now. None of them wants markup: they are a BSD text
and two generated notice blobs. Opening About went from 16538 lines of stderr to none.

The system-library blurb moves next to the notices it belongs with, so all three inputs to
the dialog are constants that get the same treatment rather than one inline string that
could quietly skip it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 cec3955872 feat(client/linux): the app icon reaches About — and the launcher, which never had it either
Adding the icon to the About dialog turned out to be one line and a missing install: the
icon existed, but only the flatpak ever shipped it. The deb, rpm, arch and nix entries all
carried `Icon=video-display` — a stock monitor glyph — so on every non-flatpak install the
launcher, the taskbar and the window switcher have been showing a generic icon this whole
time. About would have shown the same nothing.

So the icon moves out of `packaging/flatpak/` into `packaging/linux/icons/hicolor/` beside
the tray icons, which is where a shared asset belongs, and all five packagings install it
to the same hicolor path. The desktop entry names the app's own icon, and `AboutDialog`
names the app id — one identity resolved through the icon theme rather than a path anyone
has to keep in step.

A `PUNKTFUNK_SHOT_SCENE=about` scene comes along so the dialog is capturable like every
other surface; that is how the icon above was verified to actually resolve rather than
silently falling back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 56efe6ff9c fix(client/linux): nothing that appears on click may move what was clicked, and captions stop starving their controls
Two settings-row defects, both found by using it.

**The reset button was a misclick trap.** It was a suffix, so the instant a row gained an
override its control shifted left to make room — and the second click on Bitrate's "+"
landed on a revert button that had just moved under the pointer, silently undoing the edit
that created it. Both marker widgets now sit in the prefix: the control never moves, and
the general rule is worth stating since the next affordance will face it too — nothing that
appears in response to a click may displace the thing that was clicked.

**Long captions ellipsized the value beside them** ("2× (su…"). A row lays title and caption
out in one box and the control as a suffix, and that box asks for as much width as its
longest caption line wants, so the caption ate the control's space. The repo's standing rule
was "keep captions to one line, ~66 chars", which held until a row grew a second suffix —
exactly what the reset button did. Captions are now capped structurally instead, in one pass
over each page after the rows exist rather than sixteen easy-to-forget per-row calls.

The cap needs all three of `max_width_chars`, `hexpand(false)` and `halign(Start)`, and it
is worth knowing why: the first alone only limits what the label ASKS for, and a filled,
expanding label is still allocated the whole box and goes back to one long line. That is
commented at the call site so it doesn't get tidied away.

Also: the render-scale value is just "2×" now (its caption already explains what above and
below 1× mean), and the 4:4:4 caption I added last round is down from three lines to two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 31a30038fe fix(client/linux): reverting an override acts in place, and the card menu stops being a list of everything
Four things from driving it.

**Reverting one row rebuilt the whole dialog.** It closed and reopened to re-render, which
animated the entire surface for a one-row change and dropped the user back on the General
page — a heavy, disorienting answer to "undo this". A reset now clears the field, puts that
one control back to the inherited value, and drops the marker, all in place. Doing that
needs the programmatic change not to read as a touch (it would instantly re-create the
override the reset just removed), so `Touched` gained a suspend flag the revert holds; and
it needs the seed pass and the revert to agree on where each picker sits, so that index
maths is now one shared `index` module instead of two copies drifting apart.

**Hidden markers still held their space.** The dot and reset were built invisible and
revealed on touch, but an invisible prefix still costs its slot, so every un-overridden row
carried a dot's worth of inset and the column read as misaligned. They are built lazily
now — the row has nothing until it has an override.

**The colour swatches squeezed their own label.** Nine of them as an ActionRow suffix left
the title unreadable; squeezing the label to fit the control is backwards. They get a
full-width row with the caption above and a wrapping FlowBox beneath (and not a homogeneous
one, which stretched 26px circles into rounded rectangles).

**The card menu had grown to eleven entries and three submenus** — the useful ones
(connect, library, speed) buried in list management. Two rules thin it: anything that
CONFIGURES the host moves to the edit sheet, which is where you already go to change its
name; and what remains is sectioned, so a glance lands on the right third instead of
scanning eleven similar lines. The default profile and the pinned cards are properties of
the record, so they now live in the sheet — pins as a switch per profile — and the menu
keeps "Connect with", the things you look at, the two link actions, and host management.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 b0ea1e6b51 fix(client/linux): the override marker appears on touch, profiles get a colour, and 4:4:4 gets a switch
Three things found by actually driving the client.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 9243a770bc fix(host/encode): an Automatic bitrate follows the pixels actually encoded
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 2m10s
ci / bench (push) Successful in 7m34s
deb / build-publish (push) Failing after 6m38s
decky / build-publish (push) Successful in 40s
apple / swift (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
ci / rust-arm64 (push) Failing after 9m29s
ci / rust (push) Failing after 9m31s
deb / build-publish-host (push) Failing after 7m15s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 30s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 17s
android / android (push) Successful in 12m31s
apple / screenshots (push) Canceled after 4m1s
arch / build-publish (push) Canceled after 13m14s
deb / build-publish-client-arm64 (push) Canceled after 5m40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 3m48s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m39s
windows-host / package (push) Successful in 16m35s
windows-host / winget-source (push) Skipped
`build_pipeline` opens the encoder at the DELIVERED frame size but was handed a
bitrate resolved from the NEGOTIATED mode. Those agree for a virtual display,
which is created at the mode that was asked for. They do not agree for a monitor
mirror: `MirrorDisplay::create` ignores the requested mode by design (a physical
head runs at the mode its owner set), so a client asking for 1080p and mirroring a
4K panel encoded four times the pixels at the 1080p rate — a quarter of the bits
per pixel the codec was sized for, which arrives as a soft, stuttering picture
rather than as the mismatch it actually is. This is the reported "laggy mirror".

Re-resolve for what will really be encoded, and thread the applied rate back out
so the session adopts it: `bitrate_kbps` is what the ABR controller climbs from,
what the console samples, and what a `SetBitrate` ack is measured against, so
letting it disagree with the live encoder makes each of those reason about a
stream that does not exist. The mid-stream rebuild paths (mode switch, compositor
switch, capture-loss) adopt it too — each can land on a different source than it
left.

Automatic only. An explicit client rate is the operator's statement about their
own link and is never second-guessed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:23:32 +02:00
enricobuehlerandClaude Opus 5 664c26cb2b fix(vdisplay): a pin with no heads to mirror must not refuse the session
The streamed-screen pin is host-wide and persisted, but the session it applies to
is not. A box that boots between a desktop (heads to mirror) and a nested or
headless Game Mode (none) carried the pin into a session where `monitors::resolve`
could only fail — and since `vdisplay::open` is the one place every session opens a
display, that failure was a host refusing to stream at all rather than one
streaming the normal way. Pinning a monitor in Desktop mode bricked Game Mode.

A compositor reporting NO heads whatsoever now degrades to the virtual-display
path with the reason logged. Narrow on purpose: a pin that misses among heads that
DO exist stays the hard error §5.2 makes it — showing someone a different screen
than they asked for is the failure worth refusing over, and "there are no screens
here" is not that. An enumeration ERROR also stays on the mirror path, so the
session fails with the real reason instead of quietly ignoring the operator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:23:32 +02:00
enricobuehlerandClaude Opus 5 028462ef6d feat(vdisplay/gamescope): a Game Mode session drives a real head — list it, and mirror it
The per-monitor capture feature shipped with gamescope written off as "nested,
therefore no physical heads of its own", and `monitors::list` returned a
hard-coded empty Vec for it. That premise holds for a gamescope nested in another
compositor and for the headless ones this crate spawns — but not for the
deployment people actually stream from: a Bazzite/SteamOS Game Mode session is
the DRM master and drives the TV directly. On such a box the console's picker was
permanently empty, on exactly the hardware the feature was asked for.

`gamescope/heads.rs` answers the narrow question instead — is this gamescope on
the DRM backend, and which connector is it lighting? — from `/proc/<pid>/cmdline`
(the backend flag and `--prefer-output`; gamescope publishes no protocol that
reports its own output) and `/sys/class/drm` (what is plugged in, plus each
connector's EDID for the picker label and the panel's preferred timing). The
nested/headless shapes still answer an empty list, which is what they always
meant.

Mirroring needs no new capture code: gamescope composites its one head into a
PipeWire node it already publishes, so the mirror arm is an ATTACH to that node —
the gaming session is not stopped, relaunched or re-moded, which is the whole
difference between "stream the panel I am looking at" and the MANAGED takeover
that deliberately blanks it.

Refresh comes from the EDID and never from `--nested-refresh`: that flag is the
rate gamescope composites the nested game at, and reading it as the connector's
rate would let a session launched at 60 cap a 120 Hz TV for the whole stream.

Verified against home-bazzite-1's live EDID: manufacturer `1e6d` -> GSM, name
descriptor -> "LG TV SSCR2", preferred timing 594 MHz / 4400x2250 -> 3840x2160@60.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:23:32 +02:00
enricobuehler 8fee1139f6 chore(client/android): adopt AGP 9.3.1 / Gradle 9.5.0, and tell CI about it
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 3m15s
android / android (push) Canceled after 4m30s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m51s
ci / bench (push) Canceled after 4m42s
ci / rust (push) Canceled after 5m2s
ci / rust-arm64 (push) Canceled after 5m3s
deb / build-publish (push) Canceled after 4m25s
deb / build-publish-client-arm64 (push) Canceled after 36s
deb / build-publish-host (push) Canceled after 1m45s
decky / build-publish (push) Canceled after 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2s
The bump itself came from Android Studio; this makes the rest of the repo
agree with it.

The toolchain line at the top of the root build file still claimed AGP 9.2.0
and Gradle 9.4.1, and both Android workflows named "AGP 9.2" as the reason
they pin JDK 21 — the sort of comment that is trusted precisely because it
looks maintained.

The Gradle caches also needed telling: they cache the DISTRIBUTION under
`~/.gradle/wrapper`, keyed on a hash of `*.gradle.kts` only. This bump moved
both files so the key changed anyway, but a wrapper-only bump would have
restored a key that can never contain the new distribution. The wrapper
properties are in the key now.

Verified with the workflows' own commands on the new toolchain:
`:app:testDebugUnitTest -PskipRustBuild --stacktrace --rerun-tasks` (the
screenshot job, 45 tasks from cold) and `:app:assembleDebug` including the
cargo-ndk native build. AGP 9.3.1 resolves no build-tools newer than the
37.0.0 both jobs install, so their sdkmanager lines still cover it.
2026-07-29 12:17:18 +02:00
enricobuehler 93fe9f134e refactor(client/android): the colour palette reads as one object
Three things were wrong with it, and they compounded.

**The order was arbitrary** — orange, blue, pink, green, amber, violet, cyan,
rose jumped back and forth across the wheel, so it looked like a bag of
colours rather than a choice. It is sorted by hue now, one clean sweep, with
the degrees in the source so it stays that way when a colour is swapped. That
is also the order creation hands them out in, so someone making profiles one
after another walks the spectrum.

**The layout fought the form.** A fixed-size grid centred in the dialog
floated free of the left-aligned labels above it; distributed across the full
width the swatches stopped looking related to each other; and eight colours
wrapped to a short second row that read as the grid running out. Each row now
FILLS the width with its swatches sharing it equally, so the palette's edges
line up with the name field's and both rows are the same length. The palette
gained two hues to make that work: ten divides by five, eight didn't divide by
anything useful.

**Selection was a heavier circle.** A border drawn on the swatch's own edge
reads as a thicker ring, not as "this one". It is a ring OUTSIDE the disc with
a gap, plus a check — which also means the selection survives a reader who
can't tell two of these hues apart. The ring's space is always reserved, so
picking never nudges the grid.
2026-07-29 12:17:18 +02:00
enricobuehler a3ec30c287 refactor(client/android): manage a profile from its own chip, and pick its colour up front
Two things about the profile UI were backwards.

**The manage menu was parked after the last chip.** A single overflow button
at the end of a horizontally-scrolling row, acting on whichever profile
happened to be selected — so reaching it meant scrolling past every profile,
and nothing on screen tied the button to its target. It now lives ON the chip:
the selected profile grows a chevron, tapping it again opens Edit / Duplicate
/ Delete anchored underneath, and the action is attached to the object it acts
on. The wandering ⋮ is gone.

**Colour was an afterthought.** A profile was named at creation and coloured
later, through a menu item most people would never find — so every profile
looked colourless until someone went hunting, and the accent is precisely the
signal that has to be right from the first moment (it is all a bound host
card's chip and a pinned card's tint have to go on). Name and colour are now
decided together, in one dialog that serves both creation and editing, with
the next unused colour pre-selected. "Rename…" and "Change colour…" collapse
into one "Edit…", which is three menu items instead of four and one dialog
instead of two.

The chevron rides inside the chip's label rather than the `trailingIcon` slot,
for the same reason the accent dot does: those slots reserve an 18dp icon and
shrink the chip's padding, which is what made a chip with a dot sit
differently from "Default settings" beside it.

Driven on glass: created a profile with a colour chosen in the dialog, opened
the menu from the selected chip, and edited name and colour together.
2026-07-29 12:17:18 +02:00
enricobuehler f2874a5324 feat(client/android): a profile gets a colour, and you can change it
The accent was reserved in the schema and used everywhere it mattered — the
scope chip, a bound host card's chip, a pinned card's tint — but nothing ever
set one. `newProfile` left it null, and design §5.1's "Change color" was the
one item of the scope menu I didn't build. So every profile a user actually
created was colourless, and the only one that wasn't was a test profile I had
seeded by hand through adb. That inconsistency was the whole visible symptom.

Creation now hands out the first unused colour from an eight-entry palette, so
profiles are distinguishable from the moment they exist — which is the point
of the accent on the surfaces where a profile has no room for its name. The
palette avoids the presence green, which means "this host is up" and nothing
else. Past eight it wraps rather than handing out nothing: a repeated colour
beats an invisible chip, and the picker is right there.

"Change colour…" joins Rename / Duplicate / Delete, with "no colour" offered
as a real choice rather than only an initial state — a profile made before
this existed keeps working, and its chip falls back to the theme's accent.

The colour is presentation, not a setting: it is not in the overlay, so it
never reaches a resolved connect. Tested, and driven on glass end to end —
two profiles created through the dialog, distinct accents on their chips.
2026-07-29 12:17:18 +02:00
enricobuehler 4803260aff fix(client/android): a settings row's gaps are set, not inherited
One `spacedBy(4.dp)` was doing three different jobs in a settings row, and got
all of them wrong. The caption sat as close to its control as the override
marker did, so the row read as one undifferentiated stack — and the marker,
being the FIRST child of a card that already pads 16dp, added its own vertical
padding on top of that, so a marked row started with visibly double the gap
every other row has.

Each gap is now stated where it belongs: the marker has no vertical padding of
its own (it sits exactly at the card's padding, level with an unmarked row's
control) and owns the 6dp down to the field it annotates; the caption owns the
10dp up to it. Tighter above, roomier below — so the marker groups with its
control and the caption reads as a separate note rather than part of it.

The "Reset" hit area loses most of its vertical padding to make that work; it
keeps the horizontal padding and the full-width row, so the target is wide
rather than tall. Confirmed on glass, not just in the screenshots.
2026-07-29 12:17:18 +02:00
enricobuehler 7f40afc525 fix(client/android): the settings page stops shouting
Three things made the settings surface hard to read, all of them mine.

**The captions were desktop prose on a phone.** A1 took the Windows client's
`described()` text close to verbatim, which is right for a wide desktop row
and wrong for a ~340dp column: four-line paragraphs under every control, so
the page read as documentation with some dropdowns in it. Every caption is now
one line, two at the outside. The wording still says the thing that isn't
obvious from the label — "Native follows this device's refresh rate", not a
restatement of "Refresh rate" — it just stops explaining the parts nobody
needed explained.

**The override marker was taller than the control it annotates.** "Reset to
default" was a `TextButton`, which brings its own 48dp touch target, so a
marked row grew a half-height band above it that dwarfed both the field and
the caption underneath. It is one compact line now, with a padded hit area on
the word itself — the right trade for a secondary action inside a dense list.

**The scope note's spacing was lopsided**: 8dp above it, 4dp below, so it
drifted toward the chips instead of sitting between them and the divider.

The two settings screenshots cover all three.
2026-07-29 12:17:18 +02:00
enricobuehler cee39b3751 fix(client/android): two spacing slips in the profile UI
The name field in "New profile" sat flush against its caption — the `Column`
holding them had no arrangement at all, so the explanatory line read as part
of the input.

And a profile's scope chip didn't line up with the chips beside it: the accent
dot was in `FilterChip`'s `leadingIcon` slot, which reserves an 18dp icon and
shrinks the chip's leading padding to suit. A 10dp dot in that slot left the
chip's insets visibly different from "Default settings" next to it. The dot
now rides inside the label, so every chip keeps the same padding and the gap
to the name is ours to set rather than a side effect.

Both were eyeball-only surfaces: no screenshot covered either. The name field
now has one — the dialog itself can't be captured (a focused text field inside
a Dialog window never reaches idle under Robolectric, the same trap the PIN
scene documents), so its body is extracted and the scene renders exactly that,
in both the normal and duplicate-name states. The chip row was already in the
profile-scope shot.
2026-07-29 12:17:18 +02:00
enricobuehler b6819b80e2 refactor(client/android): the host card says as much in a third less space
Three stacked badges, each a coloured dot beside a word — presence, trust,
profile — turned a host card into a legend for itself, and made it ~220dp tall
for four facts. Two of the three didn't need to be badges at all.

**Presence moves onto the avatar**, the idiom every contact list already uses:
a dot on the corner, and one fewer labelled row. It is GREEN when the host is
up — a fixed green, not the scheme's primary, because Material You's primary
is whatever the wallpaper says and might itself be a green that then means
nothing. Offline is a hollow ring rather than a differently-coloured dot, so
the state survives a colour-blind reader and a greyscale screenshot; TalkBack
gets the word either way.

**Trust moves to the free top-left corner** as a glyph mirroring the overflow
on the right — locked (paired), a key (this host will ask for a PIN), an open
lock (trust-on-first-use). It costs no height at all, and it is a state you
glance at rather than read; the label rides along as the content description,
and the dialogs that actually make the trust decision spell it out in
sentences. That also retires the pill whose long label ("Trust on first use")
was what made cards tower over their neighbours in the first place.

**The profile chip stays a chip** — it is the one badge that earns the accent,
because it is the only one that says what a tap will DO.

Net: ~220dp down to ~150dp, one badge instead of three, and the only reserved
slot left is the chip's (the row-height rule still applies to it).
2026-07-29 12:17:18 +02:00
enricobuehler 6236989b03 fix(client/android): host cards in one row are the same height again
`LazyVerticalGrid` sizes a row to its tallest item but does NOT stretch the
others, so anything variable inside a card shows up as cards stepping up and
down within a single row. Two things varied.

The new one: the profile chip. A bound host's card grew ~34dp its unbound
neighbour didn't have. The chip's space is now reserved on every card in a
section as soon as ANY card there carries one — so a user with no profiles
still never sees the gap, and a mixed row is flush.

The older one, which profiles only made more visible: the trust pill. "Online"
and "Trust on first use" were laid out on one Row, so the long label wrapped
to three lines INSIDE its pill and that card towered over a "Paired" one. The
pills now wrap as pills (a FlowRow), one line each, in a reserved slot that a
two-pill card and a one-pill card both fit inside.

Both slots are `heightIn(min =)` rather than fixed, so a large accessibility
font scale grows the card instead of truncating a host's trust state — and the
pill slot is sized with room to spare rather than to an exact two lines,
because the equal-height guarantee only holds while every card fits INSIDE the
reservation.

The hosts screenshot scene now orders its mocks so an unchipped card sits
beside a chipped one, and a long trust label beside a short one — the two
shapes that used to step. Verified there and on the emulator.
2026-07-29 12:17:18 +02:00
enricobuehler 6ea10ab383 feat(client/android): the console settings read like every other surface
The console page had its own category names — Stream / Video / Audio /
Controller / Interface — which is a sixth mental model for the same settings,
on the surface least able to afford one (you navigate it a row at a time with
a D-pad). Its headers are now the shared map with the same sub-sections the
touch settings and the desktop clients use, so a setting sits in the same
place whichever surface you found it on, and its groups appear in the same
order.

The ROWS stay the couch-relevant subset. A pad can't drive a touch-input
picker, and adding one for the sake of symmetry would be parity in name only.

Retitled "Default settings": this page edits the base layer only — the console
honours a host's bound profile but doesn't edit profiles (design §5.4) — and a
bare "Settings" quietly implies it changes whatever that host streams with.
Same reason the session console and Decky are retitled.

Also carries A2's SC2 fix onto this surface: the passthrough toggle was absent
from the console page entirely, on the machines where a Steam Controller 2 is
most often the only input.
2026-07-29 12:17:18 +02:00
enricobuehler 02f7cfbb1d feat(client/android): the speed test, writing where the tested host actually reads
Android had no speed test at all — the one client where "what bitrate should
I use?" had no answer but guessing. It measures over the REAL data plane: a
minimal 720p connect, then the host bursts filler for two seconds, so the
answer is about the link this host's stream will take rather than generic
throughput. Two new JNI calls (`nativeSpeedTest` / `nativeProbeResult`) front
the core's probe, deliberately measure-only.

The measurement is the easy half. The half that was wrong on every client for
a long time is WHERE the answer goes. A measured bitrate belongs in the layer
the tested host actually resolves bitrate from (design §5.3): its bound
profile's override if it has one, the global if the host is unbound — and if
the host is bound to a profile that INHERITS bitrate, both are defensible, so
the user gets both buttons instead of us guessing. That target depends only on
the host, so it is known before the result lands and the button can say where
it will write: "Apply to “Travel”". Writing the global unconditionally — the
old behaviour everywhere — is what made measuring the slow box downstairs
quietly re-tune the desktop.

Reachable from the host card's overflow and from the console's host options: a
TV box on a powerline adapter is exactly the machine whose link is worth
measuring, even though profile editing stays off that surface.

While there: a successful write no longer renders in the error container. The
connect screen's one status line was red by design — correct for a failure,
a small lie for "75 Mbit/s set in “Travel”" — so confirmations got their own.

Verified on the emulator against a real host: 108 Mbit/s measured on a host
bound to a bitrate-setting profile, target resolved to that profile, and Apply
wrote 75397 kbps into the profile's overlay with the global untouched and the
profile's other overrides unmoved.
2026-07-29 12:17:18 +02:00
enricobuehler f6f991648d fix(client/android): a pinned console tile is a shortcut, not a second host
The touch grid withholds Edit / Forget / Wake from a pinned card on purpose —
a pin is a shortcut to one host+profile combination, and offering the host's
destructive actions on it blurs exactly that. The console carousel didn't:
its pinned tiles carried the host record, so Up on one opened the full host
options (including Forget), and Y opened the host's library.

They now carry which profile they pin, so the options dialog offers the one
action a pin has — Unpin — and says what unpinning does and doesn't touch.
2026-07-29 12:17:18 +02:00
enricobuehler 42bd5be941 fix(client/android): a link must not end the stream it isn't allowed to preempt
Found on glass. "A URL may never preempt a live session" was enforced inside
the composition — which is the one place that can't see it happen. With
`launchMode = standard` a `punktfunk://` link arrives as a SECOND activity
instance in its own task; that instance's session state is empty, so it
routed the link as a fresh connect, and the streaming task being backgrounded
ended the session it was supposed to protect. The rule held only for the rare
`FLAG_ACTIVITY_SINGLE_TOP` caller that lands on `onNewIntent`.

The live session is now published process-wide, and `onCreate` refuses there
— before this instance is ever resumed, so finishing it leaves the streaming
task in front, untouched. Static state is what crosses the gap between two
activity instances that know nothing about each other; the process dying
resets it, which is also the right answer.

Verified on the emulator against a real host: with a stream up, a link naming
a DIFFERENT host now leaves it running (same session, same HUD, no second
connect) instead of tearing it down.
2026-07-29 12:17:18 +02:00
enricobuehler 8172e819a4 fix(client/android): the JNI crate wouldn't build under the unsafe-op lint
`unsafe_op_in_unsafe_fn` is denied workspace-wide, and two operations in the
render-callback path were still bare inside their `unsafe fn` — so every
Android build failed at `cargo ndk`, before any Kotlin work could reach a
device. Pre-existing on main and unrelated to the Android settings/profiles
work; found by building the APK for it.

Both get the explicit block and the SAFETY note their neighbours in the same
file already carry: the reclaimed pointer is the one `install_render_callback`
leaked, and the callback's `userdata` is that same pointer, alive for as long
as the codec that delivers the call.
2026-07-29 12:17:18 +02:00
enricobuehler b63f9f4ac0 feat(client/android): punktfunk:// links open a stream
Android was the last client with no URL door at all: no VIEW intent filter,
no `onNewIntent`, no parser. Now a Playnite entry, an OS shortcut, a Stream
Deck macro or a wiki link can open a stream on a host this device already
trusts.

The parser is a PORT, not a new design. `clients/shared/deeplink-vectors.json`
is the cross-language contract, and the Kotlin suite runs the same 44 cases
the Rust one does — including every refusal code — so three parsers cannot
drift into three different security postures. It is resolved from the shared
path rather than copied, because a copy would be a fourth contract free to go
stale. Strict percent-decoding with a REPORTING UTF-8 decoder, so `%FF` is a
refusal rather than a U+FFFD that survives into a log line or a filename.

The routing lives in `ConnectScreen` because that is what owns the connect
path — trust decisions, the local-network grant, wake-and-retry — and a link
must go THROUGH all of it, never around it. The rules are absolute and each
one is a branch you can point at: a known, pinned host does exactly what
tapping its card does; an unknown or never-pinned one gets the confirmation
sheet, from which the normal pairing flow proceeds under the user's eyes; an
`fp=` that contradicts the stored pin is a hard refusal; an ambiguous host
name or a profile this device doesn't have refuses with a notice naming what
failed, because a "Work" shortcut streaming with the wrong settings is worse
than an error; a link arriving mid-stream never preempts it (pointing at the
host already being streamed is a no-op — the intent has already brought the
app forward, which is what focusing it means). `wake` and `browse` parse, and
are refused with a notice rather than silently connecting.

`launch=` and `profile=` ride the whole path, including through a trust
decision: a link to a host that still needs pairing keeps its game and its
profile across the confirmation instead of quietly landing on a plain desktop
session.

`launchMode` stays `standard` and the `configChanges` set is untouched — its
`keyboard` entry is what keeps an SC2 claim from killing a running stream.
The VIEW intent is read in both `onCreate` and `onNewIntent`, which is what
that launch mode requires.
2026-07-29 12:17:18 +02:00
enricobuehler 46461eb0b6 feat(client/android): profiles — per-host settings, one settings UI
Every client setting was global: pick 4K@120 HEVC and it applies to the
beefy desktop, the work laptop and the retro box alike. Android now has the
same answer the desktop clients got — named bundles of overrides, bound per
host.

A profile is a bundle of OVERRIDES, not a snapshot, and getting that right
is the whole feature. An untouched field keeps following the global live, so
fixing a global once fixes it everywhere. A touched field is recorded even
when it equals today's global — that is a pin, and it must survive the global
later moving. The only way back to inheriting is an explicit reset, never a
diff at save time. `SettingsOverlay.absorb` is the seam that makes this work
with a per-control commit: it compares against what the control was SHOWING,
not against the globals.

One settings UI, not two. A scope chips row on top switches the whole
surface between the defaults and one profile — same categories, same rows,
same captions — so a profile editor cannot drift from the thing it
overrides. In profile scope only profileable rows render (the console-UI
toggle, the library, auto-wake, the controller diagnostics are facts about
this device and simply aren't there), every row shows the effective value,
and an overridden row carries a marker and a reset.

On the host side: the Edit sheet binds a default profile and pins profiles
as cards; a bound card wears a chip naming what a tap will do; a pinned
host+profile combination gets its own card right after its host, one tap
instead of a menu — which is also what makes profiles usable on the console,
where menus are not. "Connect with" is a one-off from any card and never
rebinds; rebinding is always the explicit act of opening the Edit sheet.
Precedence is the cross-client one — one-off, else binding, else none — with
the empty reference meaning "force the defaults", a real choice on a bound
host. A deleted profile leaves a dangling binding that resolves to none and
a pin that stops rendering: never an error, never a blocked connect.

Resolution happens ONCE per connect, in `ConnectScreen`, and the resolved
settings ride the `ActiveSession` into the stream — so the "applies from the
next session" footers stay true and the stream can't disagree with the
connect that opened it. The stats overlay's first line names the profile,
which answers "which profile am I on?" from inside the stream.

Nine tests cover the model against the Rust suite's cases (apply, absorb,
pins, clear, unknown-key carry-through, id-before-name resolution with
ambiguity refused, precedence, deletion) and two Roborazzi scenes cover the
surfaces: a profile being edited, and a host grid with a bound chip and a
pinned card.
2026-07-29 12:17:18 +02:00
enricobuehler 35ce0401a8 feat(client/android): hosts get a stable identity, and own their clipboard
The host store was keyed by `"address:port"`, which had two consequences.
Editing a host's address had to re-key its record — delete the old key,
write the new one — and nothing could hold a durable reference to a host at
all, because the key moved whenever the host did. Profile bindings, pinned
cards and `punktfunk://` shortcuts all need exactly such a reference, so the
store is now keyed by a minted stable id: a lowercase UUID, the same shape
Apple's `StoredHost.id` and the Rust `KnownHost.id` already carry, so a host
reference is one grammar on every platform. The re-key-on-edit dance is
gone; an edit is a plain save.

`clipboardSync` moves from a global onto the record. It was always a
decision about a HOST — whether text on this device may cross to that
machine — and one global for the work box and the couch box was the wrong
shape; the desktop clients have had it per-host since it shipped. It is
edited from the host's Edit sheet, which is also where the profile binding
will live.

Both, plus the minted ids, ride ONE migration pass. The store is being
rewritten anyway and every extra pass is another chance to strand somebody's
hosts. It runs once against real user data, so its pure half is tested
against a verbatim pre-migration blob — every host survives with its pin,
paired flag and MACs, IPv6 addresses included; each lands on its own id;
whatever the global clipboard setting said lands on every host, on or off;
and a second pass over its own output changes nothing.

Re-trusting a host now goes through `KnownHostStore.trust`, which preserves
the existing record's identity and everything the user set on it. Three call
sites used to construct a fresh `KnownHost` — harmless while the key was the
address, but with an id it would have forked the record on every re-pair.

While the wiring was open: `StreamScreen` takes an `ActiveSession` — the
settings the connect actually resolved, plus that host's clipboard answer —
instead of re-reading `SettingsStore` behind its own connect's back. That is
also the seam profiles need.
2026-07-29 12:17:18 +02:00
enricobuehler e7c0024543 feat(client/android): the settings the shared struct had and Android didn't
Gap closure against the cross-client settings struct, plus one real bug.

`pointer_capture` was Android's private spelling of `mouse_mode`: same two
states (pointer lock + relative deltas, or free absolute pointing), a
different name and a Boolean shape, so a profile or another client could
never carry it. It becomes `MouseMode` with the shared `capture`/`desktop`
names and the shared labels, migrating from the old Boolean — whose `false`
default IS `desktop`, so an install that never touched the toggle lands
exactly where it already was. Android keeps `desktop` as its default rather
than the desktop clients' `capture`: a phone or TV is far more often driven
by touch or a pad than by a locked mouse, and that is what this platform
already did.

The pad picker gains Steam Deck, and stops assuming its index IS the wire
byte — `GamepadPref` has eleven values now and the offered subset is not a
prefix of them (Steam Deck is 6; 5, the classic Steam Controller, is not
offered, exactly as on the desktop clients).

PyroWave joins the codec table so the value is representable, but is not
offered: it is a Vulkan-compute codec that lives in `pf-presenter`, this
client decodes through MediaCodec and never advertises the bit, so choosing
it would silently resolve to HEVC. AV1's existing capability gate and this
one now share `codecOptionsFor`, which also keeps whatever IS stored
selectable — a codec chosen on another device must survive being looked at
here.

And the fix: "Steam Controller 2 passthrough" was gated on the device having
a body vibrator. It is a USB/BLE capture with nothing to do with rumbling
this device, and the gate hid it on exactly the machines that most want it —
TV boxes, where an SC2 is the whole input story. Only the rumble-mirroring
row, which genuinely needs a motor to mirror onto, stays gated.
2026-07-29 12:17:18 +02:00
enricobuehler 24a2d3a81b feat(client/android): the settings read like every other client's
Android was the last client on its own settings map (Display / Audio /
Controls / Interface / About) while the desktop and Apple clients had
converged on General / Display / Input / Audio / Controllers / About with
sub-sections inside each. Same settings, six different mental models is
five too many, and the profile scope switcher A4 adds hangs off exactly
this structure.

So: the shared category map, the sub-section headers the desktop clients
group by ("Resolution" / "Quality" / "Decoding" / "Host output"), and the
`described()` idiom — `SettingDropdown` gained a `caption` parameter, so a
dropdown's explanation belongs to the dropdown instead of floating as a
loose paragraph two rows below it. The only form-level notes left are the
two "applies from the next session" footers, one per affected category,
matching the decision Apple made.

About finally names the app and its version, the way the WinUI and Apple
About pages do.

`SettingsScreen` takes an optional `initialCategory` so the screenshot
harness can capture a category page — the headers, the captions and the
footers only exist inside one, so the root shot alone couldn't catch a
regression in them. Two new Roborazzi scenes (Display, Input) use it.
2026-07-29 12:17:18 +02:00
enricobuehlerandClaude Opus 5 861b1ffe26 fix(packaging/windows): keep the installer-run scripts ASCII, as the gate requires
ci / docs-site (push) Successful in 1m7s
android / android (push) Canceled after 1m38s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m0s
ci / web (push) Successful in 2m6s
ci / rust (push) Canceled after 2m13s
ci / rust-arm64 (push) Canceled after 2m13s
ci / bench (push) Canceled after 1m51s
deb / build-publish (push) Canceled after 58s
deb / build-publish-host (push) Canceled after 41s
deb / build-publish-client-arm64 (push) Canceled after 14s
decky / build-publish (push) Canceled after 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 6s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-host went red at the locale-safety gate: I wrote em-dashes and box-drawing
characters into four scripts under packaging/windows/, and that gate exists
precisely to stop that. Windows PowerShell 5.1 reads a BOM-less .ps1 in the active
ANSI codepage, so a non-ASCII byte mis-decodes on a German box and the script dies
with "unterminated string" — which is how the pf-vdisplay driver install once
failed silently in the field. The whole reason the install logic moved into the
compiled host exe was this exact hazard, and I reintroduced it in the comments.

Substituted to ASCII across all four (- for em-dash and the box-drawing rules).
No logic touched. The gate's own check now passes locally, all four still parse on
the runner, and make-driver-cert.ps1 -TestOnly still runs end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:15:53 +02:00
enricobuehlerandClaude Opus 5 f8cf3a0cf2 fix(packaging/windows): the decoded signing key must not outlive the build
windows-host / package (push) Failing after 22s
windows-host / winget-source (push) Skipped
android / android (push) Canceled after 2m11s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m28s
ci / rust (push) Canceled after 2m34s
ci / rust-arm64 (push) Canceled after 2m33s
ci / web (push) Canceled after 2m33s
ci / docs-site (push) Canceled after 2m34s
ci / bench (push) Canceled after 1m52s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 16s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 4s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 8s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m3s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 3m14s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m58s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m50s
Both driver builds base64-decode DRIVER_CERT_PFX_B64 to `driver-signing.pfx` on
disk and never delete it. The existing cleanup line only removes the EPHEMERAL
cert from the store — it is guarded on $cleanupCert, which is null on exactly the
path that writes this file.

That was harmless while the cert was a per-build throwaway. It is not harmless
now: the key is stable and trusted as a machine root on every box that installs
punktfunk, so a .pfx sitting in a build directory is a standing credential on the
machine that runs build jobs. Every windows-host build would have left one.

Adds Remove-SigningPfx, called after the last signing step and from a script-scope
trap so a mid-build failure doesn't strand the key either. Verified on the runner:
both scripts parse, the key is gone on the success path, gone on the failure path,
and a second call is a no-op.

The `break` in that trap is explicitness, not correctness. I first wrote it
claiming a bare trap would resume past the error and let a build finish with
unsigned drivers; measuring on the runner disproved that — bare and break behave
identically (exit 1, no resumption) for `throw` at script scope, `throw` inside a
function, and a cmdlet error under EAP=Stop. Kept, with the comment saying what
was actually measured rather than what I assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 30710b689d feat(packaging/windows): make-driver-cert.ps1 — one command for the signing key
The runbook was a dozen copy-pasted lines with three separate ways to get it
subtly wrong, so it is a script now. It prints the thumbprint, writes the two
secret values to files (not to the terminal, so they stay out of scrollback), and
never puts the private key in a certificate store — the only thing to clean up is
the output folder. `-TestOnly` does a full dry run and keeps nothing.

Three things the script exists to get right, all of which bit during testing:

Generation uses the .NET CertificateRequest API rather than
New-SelfSignedCertificate, so no key container is involved and it works over SSH.
New-SelfSignedCertificate fails there with NTE_PERM 0x80090010 — a network logon
has no key container. CONSUMING a .pfx still needs one, so the signtool self-test
reports SKIPPED over SSH instead of failing; distinguishing "cannot test here"
from "test failed" matters, because the first is a property of the session and the
second is a broken key. Any other signtool error is still fatal.

The extension set is explicit and matches what the drivers have actually been
signed with, read off the certs sitting on the runner: KeyUsage=DigitalSignature
(critical), EKU=codeSigning (non-critical), SubjectKeyIdentifier, and NO
basicConstraints. Improvising here would surface as a failed driver install on a
user's machine rather than as a build error, so it copies the known-good shape.

.NET's PKCS#12 writer, not OpenSSL — OpenSSL 3's default AES-256/PBKDF2 produces a
.pfx that Windows CryptoAPI frequently cannot read. And RandomNumberGenerator for
the passphrase, not Get-Random, which is System.Random.

Dry-run verified on the windows-amd64 runner: 3072-bit, 10-year, the three
expected extensions, self-test correctly SKIPPED with the NTE_PERM reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 622afc2c32 docs(packaging/windows): fix the driver-cert runbook's RNG and secret scope
Get-Random is System.Random — not a cryptographic RNG, and it had no business
generating the passphrase protecting a signing key. Uses RandomNumberGenerator
instead (.Create()/.GetBytes() spelling works on both PS 5.1 and PS 7).

Scope corrected to repo-level, matching MSIX_CERT_PFX_B64 next door: only
unom/punktfunk builds drivers, so there is nothing for an org-level secret to
reach. RPM_GPG_PRIVATE_KEY is org-level because other repos publish RPMs.

Also records where to run it — interactive logon only (New-SelfSignedCertificate
fails NTE_PERM over SSH, no key container on a network logon), and not on the CI
runner, which is the one machine that executes build code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 a49858f194 feat(packaging/windows): give the drivers one publisher identity, and give the trust back
The drivers had no cryptographic identity at all. Every build minted a fresh
`CN=punktfunk-driver` cert (build-pf-vdisplay.ps1, build-gamepad-drivers.ps1), and
install.rs `trust_cert` adds whatever .cer it finds in the unpacked bundle to
machine Root AND TrustedPublisher. So the signature vouched for nothing an
attacker couldn't restage — replace the bundle, ship your own cert beside your own
driver, install proceeds identically. It was ceremony to make PnP install quietly.

Worse, it leaked. `trust_cert` runs once per driver (install.rs:102 and :155), so
every upgrade added TWO more self-signed root CAs under the same name, and nothing
ever removed them: uninstalling punktfunk left trust behind that the user had no
reason to keep granting.

So: both build scripts now take a stable cert via DRIVER_CERT_PFX_B64 (they already
read the env var — windows-host.yml just never passed it) and fail closed on a v*
tag, same rule as the host and MSIX packers. `driver uninstall` purges every
`CN=punktfunk-driver` cert from both stores, and `driver install` purges before
adding, so an upgrade also collects the historical pile instead of adding to it.

Purge-before-add lives ONLY on the pf-vdisplay install path, not the gamepad one.
The installer runs vdisplay first and gamepad second; purging in both would have
the gamepad leg delete the cert the vdisplay leg just added whenever the two
bundles carry different certs — which is exactly what canary's per-build fallback
produces. Purging by subject rather than thumbprint is deliberate too: it is what
lets one install clean up certs from builds that no longer exist anywhere, and it
needs no parsing of certutil's localized output (this module exists because
locale-parsed PowerShell broke the driver install on a German box).

This does NOT make the driver download authenticated — a self-signed leaf is its
own root, so the installer must trust it for the driver to install at all. What it
buys is a fingerprint we can publish out-of-band so a substituted driver is
detectable, an allowlistable publisher, continuity across releases, and no root
accumulation. Attestation signing remains the real fix; documented as such.

Documented the key-custody trade honestly in packaging/windows/README.md: a stable
key trusted as a machine root on every install is worth stealing in a way a
throwaway never was, with no revocation path. CI secret only.

⚠️ The secrets must exist before the next v* tag or the release will fail — that
is the guard working, and the generation runbook is in that README. The fingerprint
line there is a placeholder until the key is generated.

Verified: punktfunk-host compiles clean on the windows-amd64 runner with this
install.rs (exit 0, no warnings); both build scripts parse; cargo fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 93902ff60e feat(packaging/bazzite): the sysext feed is signed, and the client refuses one that isn't
`punktfunk-sysext` checked the SHA256 of every image it downloaded, which sounds
like verification but isn't: SHA256SUMS lives on the same registry as the images
it describes, so anything able to replace an image could replace its checksum in
the same request. The checksum only ever proved the download wasn't corrupted.

Each feed now carries SHA256SUMS.asc, a detached OpenPGP signature over the
manifest, and the client verifies it before believing a line of it. The key is
packages@unom.io (AF245C506F4E4763) — the same one that already signs our RPMs,
so boxes have one key to trust and we have one key to rotate. Its public half is
baked into the script rather than fetched from the feed, because a key you fetch
from the thing you're authenticating authenticates nothing; gpg (present on
Bazzite) does the verifying against a throwaway keyring holding only that key, so
"good signature" and "signed by us" are the same statement.

Rollout: stable feeds only publish on a tag, so a `--seal` mode re-signs an
existing manifest without rebuilding an image, and every rpm.yml run seals the
OTHER channel of its Fedora major too. Canary pushes are frequent, so all live
feeds seal within a day of this landing and a key rotation propagates without
republishing anything. Until a feed is sealed the client refuses it and says so,
naming PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 as the informed way through.

Two things testing changed. The baked-key fingerprint check compared against an
empty string — the armor block is a single-quoted shell literal, so the extracted
range carried `FEED_KEY='` on its first line and gpg saw no armor at all; every
publish would have "mismatched" and, on a tag, failed the release. And `status`
captured fetch_manifest's stderr but only printed it on failure, swallowing the
ALLOW_UNSIGNED warning precisely when someone was running unverified.

Verified end to end on Bazzite 44 against a file:// feed with a throwaway key:
unsigned feed refused; ALLOW_UNSIGNED=1 proceeds and says so; wrong signer
rejected; tampered manifest rejected; good signature accepted; `update` exits 1
before touching anything on a bad feed. Publisher side: signs when the baked key
matches, refuses on mismatch, refuses with no key, and its signature round-trips
through the real client. shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 7b053a4a39 fix(packaging/rpm): a release must not publish unsigned RPMs into a gpgcheck=1 repo
Signing was already live and the docs were half right about it. `RPM_GPG_PRIVATE_KEY`
is an ORG-level secret on unom, so it is invisible in this repo's Actions secrets —
which reads exactly like "never set up", and both the rpm.yml step name and
sign-rpms.sh's header still said "dormant". Checked it on the wire instead: a
published punktfunk-web RPM carries an OpenPGP V4 EdDSA header signature from
af245c506f4e4763, the same key committed at packaging/rpm/RPM-GPG-KEY-punktfunk.

The real gap was the failure mode. README.md hands users a repo file with
gpgcheck=1, but sign-rpms.sh exits 0 when the key is missing — so an org secret
that got rotated, renamed, or not inherited would publish an unsigned release into
a repo that rejects unsigned packages, and every user's `dnf upgrade` would break
with us none the wiser. On refs/tags/v* that is now a build failure. Other builds
still fall through unsigned so forks and local builds keep working.

Docs corrected to match: the org-level location (with a wire-level check that
doesn't depend on where the secret lives), the fail-closed rule, and a note that
`rpmkeys --checksig` reporting NOKEY still means signed.

Guard tested locally: exit 1 on refs/tags/v0.21.0, exit 0 on refs/heads/main and
on an unset ref.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 5db93c1156 fix(packaging/windows): a release must not sign itself with a throwaway cert
Both pack scripts fell back to a fresh `New-SelfSignedCertificate` whenever
MSIX_CERT_PFX_B64 was absent, and said nothing about it beyond one Write-Host.
That fallback is right for canary and dev builds. On a tag it is not: an ephemeral
cert is regenerated per build, so nobody can pin it, and a release signed with one
is indistinguishable from a release signed by whoever else got to the artifact.
A secret that got renamed, rotated away, or simply wasn't inherited by a new
workflow would have downgraded a real release silently and shipped it.

pack-msix.ps1 and pack-host-installer.ps1 now resolve -RequireSignedCert (default
'auto' = true iff GITHUB_REF is refs/tags/v*) and throw instead of falling back;
pack-host-installer.ps1 also refuses -NoSign on the same condition. Reading
GITHUB_REF inside the scripts rather than taking a workflow flag means a future
packaging workflow inherits the guard instead of having to remember it.

No effect on today's releases: MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD are both
set as repo secrets, so the supplied-cert branch is the one tag builds take. This
only closes the trapdoor underneath it.

Verified on the windows-amd64 runner: both files parse, and the guard resolves
true for refs/tags/v0.21.0, false for refs/heads/main and for an unset ref, with
'true'/'false' overriding as intended.

Not touched: the pf-vdisplay / gamepad driver builds use the same pattern with
DRIVER_CERT_PFX_B64, which has no secret behind it — those need attestation
signing (a Partner Center action), and a guard there would just fail every tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 06ffca985d feat(ci/release): every release asset now ships its own SHA256 sidecar
A release page offered a DMG, an MSIX, a setup.exe, an APK and a decky zip with
nothing to check them against — the download either matched what we built or it
didn't, and there was no way for anyone to tell which.

upsert_asset now attaches `<asset>.sha256` next to each asset, so verifying is
`sha256sum -c punktfunk-1.2.3.dmg.sha256` in the download directory. Doing it in
the helper rather than in the callers means all eight packaging workflows inherit
it at once, and a future one can't forget.

Sidecars rather than one shared SHA256SUMS: those workflows attach to the SAME
release object concurrently, so a single manifest would be a read-modify-write
race that silently drops whichever leg lost. One file per asset has no shared
mutable state.

The digest is over the file, but the name written into the sidecar is the ASSET
name — callers rename on upload (Punktfunk-$VERSION.dmg), and `sha256sum -c` looks
up the name it reads. The PowerShell twin writes the line byte-exactly (LF, no
BOM): GNU sha256sum folds a trailing CR into the filename, so PowerShell's default
CRLF would have failed every check on the box doing the verifying.

Verified on both sides — bash and POSIX sh locally (`shasum -a 256 -c` passes),
pwsh on the windows-amd64 runner (91 bytes, last byte 0x0A, no CR, no BOM, same
digest as the bash path).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 80c0ca69fa fix(client/apple): take the accent wash off the scope menu too
ci / web (push) Successful in 58s
apple / swift (push) Successful in 1m27s
ci / docs-site (push) Successful in 2m58s
android / android (push) Canceled after 5m20s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m37s
ci / rust (push) Canceled after 5m44s
ci / rust-arm64 (push) Canceled after 5m42s
ci / bench (push) Canceled after 5m42s
deb / build-publish (push) Canceled after 4m56s
deb / build-publish-host (push) Canceled after 2m51s
deb / build-publish-client-arm64 (push) Canceled after 33s
decky / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 20s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s
release / apple (push) Successful in 20m41s
Same cause as the host grid's sort menu: a Menu draws its label — and the icons of
everything inside it — in the accent colour, so a control whose only meaningful
colour is the profile chips came out purple throughout.

The chips are unaffected because they are rendered bitmaps rather than tinted
symbols (see MenuIcon), so the colour that means something keeps it and the
decoration loses its. The red trash stays red for the same reason. macOS only, as
before — iOS menus already draw their icons in the label colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 d8252bb777 fix(client/apple): the sort menu was brand-purple beside two plain toolbar buttons
A Menu draws its label in the ACCENT colour where a Button draws it in the label
colour, so this one came out tinted next to Add Host and Settings. macOS only: on iOS
every toolbar item is accent-tinted already, and pinning this one to primary would
make it the odd one out there instead of in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 2e7b4aea79 fix(client/apple): a pinned card belongs to its profile, not to its host's binding
Grouping by profile asked each HOST which profile it was bound to, then handed the
answer to that host's every card. So a card visibly wearing a "Gaming" chip — a pin,
on a host bound to nothing — was filed under "No Profile" along with the rest. Every
pinned card was, which is most of what the grouping exists to separate.

Pins are not bindings. The arrangement works on CARDS now: a host's own card follows
its binding, a pinned card follows its pin, and both can land in the same band when a
host pinned the profile it is also bound to. The expansion moved out of the view and
into the arrangement, because it is exactly what the grouping has to see.

The regression is pinned by a test whose fixture is the shape that broke: a host
bound to nothing, carrying two pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 f071467cbb feat(client/apple): sort and group the host grid
The grid had no ordering story — hosts sat in the order they were added and that was
the whole of it. A toolbar menu now offers Sort by (Date Added, Name, Last Connected)
and Group by (None, Profile, Status), both per device: this is a window on a list,
not something about how a host streams. The control stays out of the toolbar until
there are two hosts to arrange.

Grouping by profile is the one the profiles made possible: bands in catalog order,
each header wearing its profile's colour, then the unbound hosts. Pinned cards stay
with their host — a pin is presentation of that host, not a host of its own — and a
dangling binding lands in "No Profile" rather than in a band named after a profile
that no longer exists. Empty bands aren't drawn.

The ordering is pure and tested, which earned its keep immediately: the test caught
that undated hosts were sorting first, and made me work out whether that was a bug.
It isn't — no date means saved before `addedAt` existed, which means older, and in a
real store those are a prefix, so the default sort leaves an upgraded grid exactly as
it was. Two other traps are pinned by tests: `sorted` is not stable in Swift, so
every comparison tie-breaks on the stored index or equal rows swap on redraw; and a
never-connected host sorts LAST under Last Connected, not first as `.distantPast`
would have it.

`StoredHost` gains `addedAt` for the date the sort actually needs — optional and
appended last, so the widget contract holds and hosts saved before it keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 556ece4bc0 fix(client/apple): don't call a paid app "free software"
The term means liberty, but it is read as price — and this app is sold on the App
Store, so the person most likely to read that line is the one who just paid for it.
The licences are unchanged and the claim was true in its own sense; it was the wrong
sense to leave ambiguous on a purchase.

It now says what is unambiguously so: the SOURCE is open under MIT or Apache-2.0.
Both copies (the form footer and the tvOS page), with a note on the wording so it
doesn't drift back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 9dcf943802 fix(client/apple): About stranded the licenses on iPad, and drew the icon unrounded
The iPad settings detail column is deliberately NOT a NavigationStack — an inner one
doubles the title bar, which the code says right where it sets it up. I put a
NavigationLink in it anyway, so opening Acknowledgements pushed into a context with
no back button and left the license wall with no way out. It is a sheet on iOS now,
the same one macOS already used; only tvOS still pushes, where the page really is
inside a stack.

And iOS hands over the app icon UNMASKED: the springboard applies the rounded shape
at draw time, so used raw it is a hard-cornered square — the filled corners. It gets
the squircle radius applied here. macOS is left alone: it bakes its own shape and
margins into the image, and clipping that would cut into the icon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 d2523a3b90 fix(client/apple): a tinted icon in a menu is a stencil, so the colour never arrived
Both the profile chips and the red trash failed for one reason: SwiftUI hands a menu
row's icon to UIKit/AppKit as a TEMPLATE image, and a template is a stencil — the
tint is discarded and the system fills in its own colour. `.foregroundStyle` on the
label's icon was never going to survive, and the destructive role only colours the
ROW (on iOS; macOS menus have no destructive styling at all), never the symbol.

`MenuIcon` rasterises the icon first and marks the bitmap `.original`, which is not a
stencil. The appearance goes into the render: `Color.brand` and the system reds are
dynamic, and a renderer with no appearance resolves them to the light variant
whatever the app is showing. Results are cached per icon and appearance — a menu
rebuilds its rows on every open, and re-rendering a bitmap to draw a 12pt dot each
time would be silly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 8233722e9e feat(client/apple): the scope dropdown carries the colours, the icons, and Delete
Every profile row in the layer picker now shows its colour chip, so the dropdown is
where you SEE the catalog rather than read it. It stays a Picker for that: the
platform draws the selection checkmark in its own column, which leaves each row's
icon free to be the chip. Hand-rolling the selection would have cost one or the
other.

Delete comes back to the menu it was taken from, next to New, Edit and Duplicate —
all four with icons now. Its warning goes with it: the count of hosts that fall back
and pinned cards that disappear is in the question, not discovered afterwards. The
editor sheet drops its own delete section rather than offering the same destructive
action in two places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 586da1451c feat(client/apple): one sheet for a profile, with the colours visible
The create/update flow was four menu items, three bare text alerts and a submenu of
colour NAMES with no colour anywhere on it. Making a "Work" profile meant: name it in
an alert, find it again in the scope menu, open a submenu, and pick "Amber" on faith.

A profile is a name and a colour, so they are decided together now — in one editor
sheet that serves create, duplicate and edit, with a live chip at the top showing
exactly what the host cards will render. The palette is swatches you can see, in a
grid, with a checkmark AND a ring on the chosen one (the tick alone washes out on the
pale hues) and a 44pt target under each 30pt dot. Default leads the row, so "no
colour" is a choice on the same shelf as the rest rather than the absence of one.

Duplicate opens the sheet rather than committing on the spot: it arrives carrying the
source's colour and overrides with a free name filled in, so it can be renamed before
it exists rather than after. Creating lands you in the new profile — being left on
the defaults is how you end up editing the wrong layer.

Delete moved into the sheet with the rest, and its warning is where the button is:
the count of hosts and pinned cards that will change sits under it before you press
it, not only in the confirmation after.

The name's uniqueness check now reports inline and in red instead of through a
disabled button and an alert message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 f50d13752d feat(client/apple): an About page worth opening, and profile colours you can actually pick
About was a bare "Punktfunk / Version x" header over 885 KB of license text — it
answered the one question nobody opens About to ask. It is now the shape every Mac
user already knows: the app's icon, its name, the version (selectable, because a bug
report is worth more with it), the tagline, and then the ways out — documentation,
community, source, licenses. The license wall is still `AcknowledgementsView`, one
push in on iOS/tvOS and a sheet on macOS, where a preferences tab has no navigation
stack to push onto.

Every platform hides the app icon somewhere different and tvOS can't hand it over at
all (layered assets, no single image), so there's a drawn fallback in the same brand
gradient and Geist monogram as the host cards — where the real icon is unavailable it
reads as a mark, not as a broken image. tvOS also has no browser: the addresses are
text to read off the screen rather than links to nowhere.

And profile colours were half-built by me: the catalog stored `accent`, the chips
tinted from it, and nothing in the app ever SET one. The scope menu grows "Color ▸"
over a fixed palette — the chip is small tinted text on a tinted capsule, and a
colour picked freehand lands somewhere unreadable often enough to matter. The palette
is what this client OFFERS, not what it accepts: any `#RRGGBB` another platform
writes still renders, which is what Android needs from this field.

The colour now shows wherever the profile is named — the scope switcher's dot, the
card chips, and the HUD's session line, which tints to the same colour the card that
launched it wore. A colour is an identifier, and an identifier that changes between
surfaces isn't one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 8b17e72af0 fix(client/settings): one marker per override, footers at footer size, air under Reset
The resolution marker hung off the Match-window toggle, which read as if that toggle
alone were overridden. Match-window, width and height are ONE override — they reset
together — so the marker belongs to the group, under the size control that ends it,
not to the first of the two rows that write it.

The new section footers inherited the app's 17pt body font, so the profile picker's
description sat visibly larger than every other description in Settings. Same style
as the footers that were already there.

And the marker carries a bordered button, which needs more air under the control
above it than a line of text does — most visible under the segmented refresh picker.
Added inside the marker, so every row that shows it is spaced alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 e9abc1a61f fix(client/settings): Reset belongs on the row's edge, not in the caption's column
The override marker sat inside the caption's width rule, so its Reset stopped where
the text stops — stranded mid-row, short of the switch it undoes. The rule is for
TEXT: Reset is a control, and it lines up with the row's own control above it. The
marker now spans the cell and only the caption is capped and inset.

The caption's reserved column widens with it (60 → 76): it should stop visibly short
of the control, not graze it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 c1d2efe112 fix(client/apple): the Name field didn't say it was the name either
Same cause as the MAC one, and it deserved the same fix rather than another
one-off: on iOS a TextField's title becomes an accessibility label the moment a
prompt exists and nothing is drawn, so "Optional — e.g. Living Room" was the whole
of what the field said about itself.

The prompt is now built per platform. macOS draws the title as a leading label, so
its prompt only hints at the value; iOS has no label to lean on, so the prompt names
the field too. One string for both leaves you either a Mac panel that says
everything twice — which is what the MAC field started doing in the last commit — or
a phone form of unlabelled boxes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 34a9e32bf8 fix(client/apple): put the add-host sheet back, and fix the MAC field where it's read
The footer was the wrong instrument and I should have checked what the screen
actually shows before adding one. On iOS a field's title becomes its accessibility
label once a prompt is given, so "MAC" was never on screen — the PROMPT was, and
"Wake-on-LAN — auto-filled when known" read as if that were the value being asked
for. It now says what the field is: "MAC address (for Wake-on-LAN, optional)". No
explanatory paragraph under the group.

Height and scrolling go back to what they were: a 392pt sheet sized to its content
with nothing to scroll. The edit sheet's profile rows are the only thing that can
outgrow that, and only they extend the detent and turn scrolling back on — a single
fixed number is what clipped them in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 282d691b76 fix(client/apple): give the add-host sheet back to adding a host
Adding a host is about reaching it: where it is, and whether we can wake it. Which
settings it streams with is a decision about a host you already have. Stacking the
profile binding and the pin toggles onto the add flow made the first thing a new
user meets a longer form than the one they came for — those rows are edit-only now,
and editing is one context-menu item away.

The pins lost their disclosure with it. A collapsed group had to animate its own
height AND the sheet's, and got both wrong — no transition, then a clipped list.
With a profile or three these are a couple of rows, and rows that are simply there
can't fail to expand.

"MAC" named the value and said nothing about why the field is there, while
"Wake-on-LAN" sat in the placeholder as if it were something to type in. The label
is "MAC address" and the section footer says what it buys: waking a sleeping host,
filled in by itself once the host has been seen. Same relabelling on tvOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 1d51d83acf fix(client/apple): the add-host sheet was Mac-sized on iPhone, and clipped its own pins
Two things, one cause: the sheet was written as a Mac panel and iOS inherited it.

The 12pt Geist and `.controlSize(.small)` exist because a grouped form's system text
reads oversized next to the app's typography in a Mac panel. Applied to iOS as well,
they made this the one sheet in the app you had to squint at — a touch target and a
field label there are not a Mac panel's. macOS keeps them; iOS keeps the app's body
size.

And the sheet's height was a single hardcoded number with scrolling switched off, so
expanding "Pinned cards" grew content into a height nobody recomputed and the toggles
were simply clipped — the disclosure looked broken because there was nowhere for it
to go. The height now follows what the sheet is showing (base fields, the picker when
profiles exist, the toggles while expanded, which is why the disclosure's expansion is
bound state now), scrolling is back on so nothing can be stranded, and `.large` is
offered alongside for the accessibility text sizes these estimates won't cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 cf55a11174 fix(client/settings): captions ran under the switch on iPhone
The `described` idiom capped its caption at 360pt, which was only ever about
READING — past roughly 46 characters a line stops scanning well on a wide Mac window
or an iPad detail pane. On an iPhone the cell is narrower than the cap in the first
place, so the cap did nothing and every caption laid out to the full cell width,
running its last line straight under the row's switch.

`CaptionWidth` now carries both limits: the reading cap, plus an iOS trailing inset
that reserves the control column (a `UISwitch` is 51pt, so 60 with room). Order
matters — the inset shrinks what the text is offered, then the cap applies, so a
narrow phone cell clears the switch and a wide pane still caps.

One rule in one place: the override marker and the iOS resolution wheel's caption
each carried their own copy of the 360, and neither would have grown the inset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 9385b61ac2 fix(client/apple): the scope switcher was a Mac control dropped into an iPhone list
One chrome served both platforms and only worked on one. In the iOS settings list
the macOS shape — a bordered `.menuStyle(.button)` with the caption stacked under it
— rendered as neither a row nor a control: icon only, no label, and four lines of
wrapped caption making the first thing on the screen a block of text.

The menu CONTENTS stay one definition; the chrome is now per platform. macOS keeps
the button menu heading the preferences window. iOS gets a standard value row —
"Editing" on the left, the current layer on the right, the system's up/down chevron
— with the caption where a caption belongs in a grouped list: the section footer.

The two prompts the menu can raise moved into a shared `profilePrompts` so both
chromes carry them without a second copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 afb33d510f fix(client/apple): anchor the profile chip to the card's trailing edge
On the title line it sat immediately after the host name, so it read as part of the
title rather than as a badge on the card. A spacer between them anchors it to the
trailing edge, and the text column now fills the card — without that it hugs its
content and "trailing" only ever means "just after the name".

The spacer is also what keeps the two apart: the host name truncates against the gap
instead of running into the chip, and the chip keeps layout priority because a
truncated host name still reads while a truncated profile name is the one thing the
chip exists to say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 82f5962bec fix(client/apple): three things the first on-glass run found
Reset read as prose. Next to "Overrides Default settings" in the same tint and the
same size, the one action that can undo an override looked like the third word of
the notice. It is a bordered control now, pushed to the far edge of the caption
line with an undo glyph.

The scope switcher showed above the About tab. It sat over the whole macOS TabView,
so on the acknowledgements page — which edits nothing — it read as belonging to
them. The tabs are tagged and the switcher sits that one out.

Pinned cards were taller than their host's. The profile chip had a line of its own,
so a card with a profile and one without were different heights and a pin stuck out
of its grid row. The chip rides the title line instead, which is also where it reads
as "this card connects with that". Prominence is fill and weight only — a chip with
a bigger type size would have brought the height difference straight back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 0a32f4eab1 fix(client/apple): the iOS slice hit the type-checker wall, and tvOS couldn't be checked at all
`ContentView.body` grew two modifiers and stopped type-checking on the iOS slice —
"unable to type-check this expression in reasonable time", a failure macOS builds
never show and the one Apple CI never builds. Split into the screen plus its
lifecycle drivers, then the prompt chain, with each alert's presentation Binding
lifted out the way the deep-link one already was.

tvOS couldn't be checked from the command line at all: HomeView imports the slide
transition, which ships with the Xcode project only because its manifest breaks
SwiftPM's whole-graph validation. `canImport` instead of a bare `os(tvOS)` gate
makes the tvOS sources compile without it, and the app still gets the transition
because there the module is present. Both slices now typecheck by hand.

The scope switcher is tvOS-gated with them: a name prompt and a nested management
menu are not what a remote does well, and §5.4 keeps profile EDITING off
controller-first surfaces in v1 — they honor bindings and render pinned cards.

Also: release the session's settings latch when a connect fails, is refused, or is
abandoned mid-handshake. Only `disconnect` cleared it, so a failed dial left the
would-be session's resolution latched over the globals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 25b127803f feat(client/apple): bind a host to a profile, connect with one, pin one as its own card
The host surfaces (design §5.2/§5.2a). A bound host wears a tinted chip that says
what a click will do. The card menu grows "Connect with ▸" — a ONE-OFF that never
rebinds, with a checkmark on the binding and an explicit "Set Default Profile" as
its last item for the users who do want to rebind from there — plus "Pin as Card ▸"
and "Copy Link".

A pinned host+profile combo becomes its own card next to its host: same record,
same live status, the profile as the prominent subtitle, one click to connect. It
is an extra grid ENTRY, never a duplicated host record — duplicating would fork
pairing, Wake-on-LAN and renames. Its menu carries only connect-shaped actions;
edit, pair, forget and remove stay on the primary card, where the thing they act on
lives. On the gamepad carousel and tvOS the same pins are tiles, which is the whole
point: focus-and-press is what those surfaces do well, and menus are not.

The edit sheet binds and pins; both rows vanish when no profiles exist, so a user
who never makes one sees exactly today's sheet. A dangling binding renders as
"Default settings (profile deleted)" and is cleaned up on save.

The speed test finally writes where the tested host reads. Unbound: the global, as
before. Bound to a profile that overrides bitrate: that override. Bound to one that
inherits it: both are offered, because either is defensible and guessing would
silently pick one. Every button names its target, and the probe now runs at the
mode that host would actually stream — the measurement is the streaming path.

Shortcuts gains `ProfileEntity` over the App-Group catalog and a Profile parameter
on Connect, still round-tripping through the URL router rather than opening a second
connect path. Connect and Wake drop their iOS wall — AppIntents is real on macOS and
tvOS, and "Stream Desktop with Work" from Spotlight was the ask; only the phrases
provider stays iOS-gated, since it bundles the LiveActivityIntent. The deep-link
observer moved out with them: an intent that posts to nobody is a shortcut that
silently does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 3ae9b83290 feat(client/apple): the settings screen edits profiles, and says which rows it changed
One settings surface, two layers. A scope switcher at the top of the Mac window and
the iOS sidebar swaps the whole screen between Default settings and one profile's
overrides; the section builders stay the single definition of every row and just
change which layer their binding reads and writes (design §5.1). A parallel profile
editor would have drifted from this one field by field, and the revamp's captions
and curation would have had to be written twice.

`SettingsFields` is where a row's three faces meet — the UserDefaults key its global
lives under, the overlay slot an override lives in, and the name a reset carries.
Keeping them in one place is what stops a row from writing an override the reset
button can't find.

Every row shows the EFFECTIVE value, so an untouched row reads as the live global.
Touching a control records the override — always, even when the new value equals
today's global, because that is a pin and the profile must keep it when the global
later moves. Nothing is inferred by diffing at save time.

Overridden rows say so (accent dot + "Overrides Default settings") and carry the
only way back: an explicit Reset. That pair is not garnish. The model deliberately
never infers "not overridden" from a value comparison, so without a reset affordance
a profile is a one-way door.

Tier-G and tier-H rows don't render in profile scope at all — this device's speaker,
microphone and channel, its pointer capture and haptics, the HUD corner, the library
switch, the gamepad-UI switch, which physical pad is forwarded, and auto-wake, which
is about a host and a network rather than about Game vs Work. Sections that would be
left empty (Session off macOS, Library) collapse instead of rendering a bare group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 858cf0e535 feat(client/apple): profiles, resolved once per connect instead of ten times mid-session
The Apple half of settings profiles (design/client-settings-profiles.md §4) starts
with the thing the desktop shells got for free from the shared Rust core: a model,
and one place that resolves it.

`SettingsOverlay`/`StreamProfile`/`ProfileCatalog` mirror `pf-client-core::profiles`
field for field, unknown-key carry-through included — an older build that opens a
profile a newer one wrote must not gut it on save. The catalog lives in the App
Group suite beside the saved hosts, because the things that point at it are fields
on the host record: `StoredHost` gains `profileID` and `pinnedProfileIDs`, both
optional and appended last, because that JSON is a widget contract.

`EffectiveSettings` is the resolution — globals with the session's overlay on top,
computed once at connect and latched in `SessionSettings` for the rest of it. That
latch is the point. Ten sites across the app AND the kit read `UserDefaults`
directly mid-session (the presenter's priority and VRR, the vsync flip, the
match-window follower, the scroll sign, the touch model, the mouse model, 4:4:4,
the audio endpoints); a profile that reached some of them and not others would be
worse than no feature at all. They now read the latch, which off-session is the
plain globals — byte for byte what they saw before.

The deep-link grammar grows up with it: `DeepLink` becomes a full port of
`deeplink.rs` — routes, host-ref forms, `fp`/`host` recovery, one-off `profile`,
and every refusal code — and the Swift suite now runs
`clients/shared/deeplink-vectors.json` itself, read from the source tree so there
is no second copy to drift. All 44 cases green. The router refuses what it can't
honor by name: a profile that doesn't exist, an ambiguous one, a fingerprint that
contradicts the pin. A shortcut that streams with the wrong settings is worse than
one that explains itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehler 9b00ad6658 fix(client/windows): prove the deep-link module the new deny caught
ci / docs-site (push) Successful in 1m18s
ci / web (push) Successful in 2m1s
apple / swift (push) Successful in 4m46s
android / android (push) Failing after 6m31s
deb / build-publish-host (push) Failing after 5m29s
ci / rust (push) Failing after 7m33s
arch / build-publish (push) Failing after 7m35s
ci / bench (push) Failing after 7m30s
deb / build-publish (push) Failing after 6m29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 24s
decky / build-publish (push) Successful in 31s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
ci / rust-arm64 (push) Successful in 10m5s
deb / build-publish-client-arm64 (push) Successful in 7m48s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m5s
docker / deploy-docs (push) Successful in 27s
flatpak / build-publish (push) Failing after 8m30s
windows-host / package (push) Successful in 19m0s
windows-host / winget-source (push) Skipped
docker / build-push-arm64cross (push) Successful in 5m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 14m1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 14m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m49s
release / apple (push) Successful in 29m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m58s
apple / screenshots (push) Successful in 23m16s
`deeplink.rs` arrived with c27065c2 (punktfunk:// handling) while this branch was in flight, so its
eight `unsafe` blocks predate the crate's `deny(clippy::undocumented_unsafe_blocks)` and only became
visible on the rebase. This is the ratchet doing its job on brand-new code, and it is also the whole
argument for turning the convention into a lint: the module was written correctly and documented
prosaically, but nothing had required a proof at each block.

The one worth reading is the `WM_COPYDATA` handler, which dereferences an `lparam` from ANOTHER
PROCESS and builds a `u16` slice from the sender's pointer and length. What makes that sound is not
that the sender is trusted — anyone can post `WM_COPYDATA` — but that the OS marshals both the
struct and its buffer into this process and keeps them valid for the handler's duration, and that
`len` is `cbData / 2` so the slice cannot outrun the copy even for an odd `cbData`. The proof says
that, so the next reader knows which half of it is a guarantee and which is just a tag check.

⚠ Caught only because Windows was re-verified AFTER the rebase. A Linux-only check was clean —
`deeplink.rs` does not exist there — so pushing on that evidence would have re-broken Windows CI,
which is the same mistake as the `warn`-that-was-really-`deny`, one rebase later.

Verified: Windows .47 full CI clippy set + the Windows-only crates, rc=0, pf-capture's 18 tests
pass; Linux .21 fmt + both CI clippy steps rc=0.
2026-07-29 08:54:12 +02:00