9614e1a92ec32992f2a3957944033afd912ad5ad
553
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
27039e04ff |
Revert "refactor(client/windows): page-only state climbs back down from root, on the measured rules"
This reverts commit 427f99a06647fd99e5c72d18cb7a710d8a7ac885. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
61bdf11e4d |
fix(client/session): declare the GTK deps the session shell has been using
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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).
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|