Commit Graph
555 Commits
Author SHA1 Message Date
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 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 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 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 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
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
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