61bdf11e4df7fd0f80c581c62b8fd75bc0427a4d
2066
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
61bdf11e4d |
fix(client/session): declare the GTK deps the session shell has been using
|
||
|
|
907985a0b9 |
docs(packaging/windows): record the driver-signing fingerprint
The stable cert is live and CI is signing with it. Fills in the placeholder in both docs and adds the one-liner to check an installed driver against it. Thumbprint 4B8493E7CD565758D335F8F4F05C5A7261A13E02, verified off the .cer a real windows-host build produced on the runner, not from the key material: RSA 3072, valid to 2036, extensions 2.5.29.15/37/14 and nothing else. The decisive part is that the pf-vdisplay and gamepad bundles now carry the SAME thumbprint — the per-build fallback had each script mint its own 2048-bit cert, so they never matched. That is what proves the secret reached the build rather than the fallback quietly running again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
9243a770bc |
fix(host/encode): an Automatic bitrate follows the pixels actually encoded
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 2m10s
ci / bench (push) Successful in 7m34s
deb / build-publish (push) Failing after 6m38s
decky / build-publish (push) Successful in 40s
apple / swift (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
ci / rust-arm64 (push) Failing after 9m29s
ci / rust (push) Failing after 9m31s
deb / build-publish-host (push) Failing after 7m15s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 30s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 17s
android / android (push) Successful in 12m31s
apple / screenshots (push) Canceled after 4m1s
arch / build-publish (push) Canceled after 13m14s
deb / build-publish-client-arm64 (push) Canceled after 5m40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 3m48s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m39s
windows-host / package (push) Successful in 16m35s
windows-host / winget-source (push) Skipped
`build_pipeline` opens the encoder at the DELIVERED frame size but was handed a bitrate resolved from the NEGOTIATED mode. Those agree for a virtual display, which is created at the mode that was asked for. They do not agree for a monitor mirror: `MirrorDisplay::create` ignores the requested mode by design (a physical head runs at the mode its owner set), so a client asking for 1080p and mirroring a 4K panel encoded four times the pixels at the 1080p rate — a quarter of the bits per pixel the codec was sized for, which arrives as a soft, stuttering picture rather than as the mismatch it actually is. This is the reported "laggy mirror". Re-resolve for what will really be encoded, and thread the applied rate back out so the session adopts it: `bitrate_kbps` is what the ABR controller climbs from, what the console samples, and what a `SetBitrate` ack is measured against, so letting it disagree with the live encoder makes each of those reason about a stream that does not exist. The mid-stream rebuild paths (mode switch, compositor switch, capture-loss) adopt it too — each can land on a different source than it left. Automatic only. An explicit client rate is the operator's statement about their own link and is never second-guessed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
664c26cb2b |
fix(vdisplay): a pin with no heads to mirror must not refuse the session
The streamed-screen pin is host-wide and persisted, but the session it applies to is not. A box that boots between a desktop (heads to mirror) and a nested or headless Game Mode (none) carried the pin into a session where `monitors::resolve` could only fail — and since `vdisplay::open` is the one place every session opens a display, that failure was a host refusing to stream at all rather than one streaming the normal way. Pinning a monitor in Desktop mode bricked Game Mode. A compositor reporting NO heads whatsoever now degrades to the virtual-display path with the reason logged. Narrow on purpose: a pin that misses among heads that DO exist stays the hard error §5.2 makes it — showing someone a different screen than they asked for is the failure worth refusing over, and "there are no screens here" is not that. An enumeration ERROR also stays on the mirror path, so the session fails with the real reason instead of quietly ignoring the operator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
028462ef6d |
feat(vdisplay/gamescope): a Game Mode session drives a real head — list it, and mirror it
The per-monitor capture feature shipped with gamescope written off as "nested, therefore no physical heads of its own", and `monitors::list` returned a hard-coded empty Vec for it. That premise holds for a gamescope nested in another compositor and for the headless ones this crate spawns — but not for the deployment people actually stream from: a Bazzite/SteamOS Game Mode session is the DRM master and drives the TV directly. On such a box the console's picker was permanently empty, on exactly the hardware the feature was asked for. `gamescope/heads.rs` answers the narrow question instead — is this gamescope on the DRM backend, and which connector is it lighting? — from `/proc/<pid>/cmdline` (the backend flag and `--prefer-output`; gamescope publishes no protocol that reports its own output) and `/sys/class/drm` (what is plugged in, plus each connector's EDID for the picker label and the panel's preferred timing). The nested/headless shapes still answer an empty list, which is what they always meant. Mirroring needs no new capture code: gamescope composites its one head into a PipeWire node it already publishes, so the mirror arm is an ATTACH to that node — the gaming session is not stopped, relaunched or re-moded, which is the whole difference between "stream the panel I am looking at" and the MANAGED takeover that deliberately blanks it. Refresh comes from the EDID and never from `--nested-refresh`: that flag is the rate gamescope composites the nested game at, and reading it as the connector's rate would let a session launched at 60 cap a 120 Hz TV for the whole stream. Verified against home-bazzite-1's live EDID: manufacturer `1e6d` -> GSM, name descriptor -> "LG TV SSCR2", preferred timing 594 MHz / 4400x2250 -> 3840x2160@60. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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.
|
||
|
|
861b1ffe26 |
fix(packaging/windows): keep the installer-run scripts ASCII, as the gate requires
ci / docs-site (push) Successful in 1m7s
android / android (push) Canceled after 1m38s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m0s
ci / web (push) Successful in 2m6s
ci / rust (push) Canceled after 2m13s
ci / rust-arm64 (push) Canceled after 2m13s
ci / bench (push) Canceled after 1m51s
deb / build-publish (push) Canceled after 58s
deb / build-publish-host (push) Canceled after 41s
deb / build-publish-client-arm64 (push) Canceled after 14s
decky / build-publish (push) Canceled after 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 6s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-host went red at the locale-safety gate: I wrote em-dashes and box-drawing characters into four scripts under packaging/windows/, and that gate exists precisely to stop that. Windows PowerShell 5.1 reads a BOM-less .ps1 in the active ANSI codepage, so a non-ASCII byte mis-decodes on a German box and the script dies with "unterminated string" — which is how the pf-vdisplay driver install once failed silently in the field. The whole reason the install logic moved into the compiled host exe was this exact hazard, and I reintroduced it in the comments. Substituted to ASCII across all four (- for em-dash and the box-drawing rules). No logic touched. The gate's own check now passes locally, all four still parse on the runner, and make-driver-cert.ps1 -TestOnly still runs end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f8cf3a0cf2 |
fix(packaging/windows): the decoded signing key must not outlive the build
windows-host / package (push) Failing after 22s
windows-host / winget-source (push) Skipped
android / android (push) Canceled after 2m11s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m28s
ci / rust (push) Canceled after 2m34s
ci / rust-arm64 (push) Canceled after 2m33s
ci / web (push) Canceled after 2m33s
ci / docs-site (push) Canceled after 2m34s
ci / bench (push) Canceled after 1m52s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 16s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 4s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 8s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m3s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 3m14s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m58s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m50s
Both driver builds base64-decode DRIVER_CERT_PFX_B64 to `driver-signing.pfx` on disk and never delete it. The existing cleanup line only removes the EPHEMERAL cert from the store — it is guarded on $cleanupCert, which is null on exactly the path that writes this file. That was harmless while the cert was a per-build throwaway. It is not harmless now: the key is stable and trusted as a machine root on every box that installs punktfunk, so a .pfx sitting in a build directory is a standing credential on the machine that runs build jobs. Every windows-host build would have left one. Adds Remove-SigningPfx, called after the last signing step and from a script-scope trap so a mid-build failure doesn't strand the key either. Verified on the runner: both scripts parse, the key is gone on the success path, gone on the failure path, and a second call is a no-op. The `break` in that trap is explicitness, not correctness. I first wrote it claiming a bare trap would resume past the error and let a build finish with unsigned drivers; measuring on the runner disproved that — bare and break behave identically (exit 1, no resumption) for `throw` at script scope, `throw` inside a function, and a cmdlet error under EAP=Stop. Kept, with the comment saying what was actually measured rather than what I assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
30710b689d |
feat(packaging/windows): make-driver-cert.ps1 — one command for the signing key
The runbook was a dozen copy-pasted lines with three separate ways to get it subtly wrong, so it is a script now. It prints the thumbprint, writes the two secret values to files (not to the terminal, so they stay out of scrollback), and never puts the private key in a certificate store — the only thing to clean up is the output folder. `-TestOnly` does a full dry run and keeps nothing. Three things the script exists to get right, all of which bit during testing: Generation uses the .NET CertificateRequest API rather than New-SelfSignedCertificate, so no key container is involved and it works over SSH. New-SelfSignedCertificate fails there with NTE_PERM 0x80090010 — a network logon has no key container. CONSUMING a .pfx still needs one, so the signtool self-test reports SKIPPED over SSH instead of failing; distinguishing "cannot test here" from "test failed" matters, because the first is a property of the session and the second is a broken key. Any other signtool error is still fatal. The extension set is explicit and matches what the drivers have actually been signed with, read off the certs sitting on the runner: KeyUsage=DigitalSignature (critical), EKU=codeSigning (non-critical), SubjectKeyIdentifier, and NO basicConstraints. Improvising here would surface as a failed driver install on a user's machine rather than as a build error, so it copies the known-good shape. .NET's PKCS#12 writer, not OpenSSL — OpenSSL 3's default AES-256/PBKDF2 produces a .pfx that Windows CryptoAPI frequently cannot read. And RandomNumberGenerator for the passphrase, not Get-Random, which is System.Random. Dry-run verified on the windows-amd64 runner: 3072-bit, 10-year, the three expected extensions, self-test correctly SKIPPED with the NTE_PERM reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
622afc2c32 |
docs(packaging/windows): fix the driver-cert runbook's RNG and secret scope
Get-Random is System.Random — not a cryptographic RNG, and it had no business generating the passphrase protecting a signing key. Uses RandomNumberGenerator instead (.Create()/.GetBytes() spelling works on both PS 5.1 and PS 7). Scope corrected to repo-level, matching MSIX_CERT_PFX_B64 next door: only unom/punktfunk builds drivers, so there is nothing for an org-level secret to reach. RPM_GPG_PRIVATE_KEY is org-level because other repos publish RPMs. Also records where to run it — interactive logon only (New-SelfSignedCertificate fails NTE_PERM over SSH, no key container on a network logon), and not on the CI runner, which is the one machine that executes build code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a49858f194 |
feat(packaging/windows): give the drivers one publisher identity, and give the trust back
The drivers had no cryptographic identity at all. Every build minted a fresh `CN=punktfunk-driver` cert (build-pf-vdisplay.ps1, build-gamepad-drivers.ps1), and install.rs `trust_cert` adds whatever .cer it finds in the unpacked bundle to machine Root AND TrustedPublisher. So the signature vouched for nothing an attacker couldn't restage — replace the bundle, ship your own cert beside your own driver, install proceeds identically. It was ceremony to make PnP install quietly. Worse, it leaked. `trust_cert` runs once per driver (install.rs:102 and :155), so every upgrade added TWO more self-signed root CAs under the same name, and nothing ever removed them: uninstalling punktfunk left trust behind that the user had no reason to keep granting. So: both build scripts now take a stable cert via DRIVER_CERT_PFX_B64 (they already read the env var — windows-host.yml just never passed it) and fail closed on a v* tag, same rule as the host and MSIX packers. `driver uninstall` purges every `CN=punktfunk-driver` cert from both stores, and `driver install` purges before adding, so an upgrade also collects the historical pile instead of adding to it. Purge-before-add lives ONLY on the pf-vdisplay install path, not the gamepad one. The installer runs vdisplay first and gamepad second; purging in both would have the gamepad leg delete the cert the vdisplay leg just added whenever the two bundles carry different certs — which is exactly what canary's per-build fallback produces. Purging by subject rather than thumbprint is deliberate too: it is what lets one install clean up certs from builds that no longer exist anywhere, and it needs no parsing of certutil's localized output (this module exists because locale-parsed PowerShell broke the driver install on a German box). This does NOT make the driver download authenticated — a self-signed leaf is its own root, so the installer must trust it for the driver to install at all. What it buys is a fingerprint we can publish out-of-band so a substituted driver is detectable, an allowlistable publisher, continuity across releases, and no root accumulation. Attestation signing remains the real fix; documented as such. Documented the key-custody trade honestly in packaging/windows/README.md: a stable key trusted as a machine root on every install is worth stealing in a way a throwaway never was, with no revocation path. CI secret only. ⚠️ The secrets must exist before the next v* tag or the release will fail — that is the guard working, and the generation runbook is in that README. The fingerprint line there is a placeholder until the key is generated. Verified: punktfunk-host compiles clean on the windows-amd64 runner with this install.rs (exit 0, no warnings); both build scripts parse; cargo fmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
93902ff60e |
feat(packaging/bazzite): the sysext feed is signed, and the client refuses one that isn't
`punktfunk-sysext` checked the SHA256 of every image it downloaded, which sounds like verification but isn't: SHA256SUMS lives on the same registry as the images it describes, so anything able to replace an image could replace its checksum in the same request. The checksum only ever proved the download wasn't corrupted. Each feed now carries SHA256SUMS.asc, a detached OpenPGP signature over the manifest, and the client verifies it before believing a line of it. The key is packages@unom.io (AF245C506F4E4763) — the same one that already signs our RPMs, so boxes have one key to trust and we have one key to rotate. Its public half is baked into the script rather than fetched from the feed, because a key you fetch from the thing you're authenticating authenticates nothing; gpg (present on Bazzite) does the verifying against a throwaway keyring holding only that key, so "good signature" and "signed by us" are the same statement. Rollout: stable feeds only publish on a tag, so a `--seal` mode re-signs an existing manifest without rebuilding an image, and every rpm.yml run seals the OTHER channel of its Fedora major too. Canary pushes are frequent, so all live feeds seal within a day of this landing and a key rotation propagates without republishing anything. Until a feed is sealed the client refuses it and says so, naming PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 as the informed way through. Two things testing changed. The baked-key fingerprint check compared against an empty string — the armor block is a single-quoted shell literal, so the extracted range carried `FEED_KEY='` on its first line and gpg saw no armor at all; every publish would have "mismatched" and, on a tag, failed the release. And `status` captured fetch_manifest's stderr but only printed it on failure, swallowing the ALLOW_UNSIGNED warning precisely when someone was running unverified. Verified end to end on Bazzite 44 against a file:// feed with a throwaway key: unsigned feed refused; ALLOW_UNSIGNED=1 proceeds and says so; wrong signer rejected; tampered manifest rejected; good signature accepted; `update` exits 1 before touching anything on a bad feed. Publisher side: signs when the baked key matches, refuses on mismatch, refuses with no key, and its signature round-trips through the real client. shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7b053a4a39 |
fix(packaging/rpm): a release must not publish unsigned RPMs into a gpgcheck=1 repo
Signing was already live and the docs were half right about it. `RPM_GPG_PRIVATE_KEY` is an ORG-level secret on unom, so it is invisible in this repo's Actions secrets — which reads exactly like "never set up", and both the rpm.yml step name and sign-rpms.sh's header still said "dormant". Checked it on the wire instead: a published punktfunk-web RPM carries an OpenPGP V4 EdDSA header signature from af245c506f4e4763, the same key committed at packaging/rpm/RPM-GPG-KEY-punktfunk. The real gap was the failure mode. README.md hands users a repo file with gpgcheck=1, but sign-rpms.sh exits 0 when the key is missing — so an org secret that got rotated, renamed, or not inherited would publish an unsigned release into a repo that rejects unsigned packages, and every user's `dnf upgrade` would break with us none the wiser. On refs/tags/v* that is now a build failure. Other builds still fall through unsigned so forks and local builds keep working. Docs corrected to match: the org-level location (with a wire-level check that doesn't depend on where the secret lives), the fail-closed rule, and a note that `rpmkeys --checksig` reporting NOKEY still means signed. Guard tested locally: exit 1 on refs/tags/v0.21.0, exit 0 on refs/heads/main and on an unset ref. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5db93c1156 |
fix(packaging/windows): a release must not sign itself with a throwaway cert
Both pack scripts fell back to a fresh `New-SelfSignedCertificate` whenever MSIX_CERT_PFX_B64 was absent, and said nothing about it beyond one Write-Host. That fallback is right for canary and dev builds. On a tag it is not: an ephemeral cert is regenerated per build, so nobody can pin it, and a release signed with one is indistinguishable from a release signed by whoever else got to the artifact. A secret that got renamed, rotated away, or simply wasn't inherited by a new workflow would have downgraded a real release silently and shipped it. pack-msix.ps1 and pack-host-installer.ps1 now resolve -RequireSignedCert (default 'auto' = true iff GITHUB_REF is refs/tags/v*) and throw instead of falling back; pack-host-installer.ps1 also refuses -NoSign on the same condition. Reading GITHUB_REF inside the scripts rather than taking a workflow flag means a future packaging workflow inherits the guard instead of having to remember it. No effect on today's releases: MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD are both set as repo secrets, so the supplied-cert branch is the one tag builds take. This only closes the trapdoor underneath it. Verified on the windows-amd64 runner: both files parse, and the guard resolves true for refs/tags/v0.21.0, false for refs/heads/main and for an unset ref, with 'true'/'false' overriding as intended. Not touched: the pf-vdisplay / gamepad driver builds use the same pattern with DRIVER_CERT_PFX_B64, which has no secret behind it — those need attestation signing (a Partner Center action), and a guard there would just fail every tag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
06ffca985d |
feat(ci/release): every release asset now ships its own SHA256 sidecar
A release page offered a DMG, an MSIX, a setup.exe, an APK and a decky zip with nothing to check them against — the download either matched what we built or it didn't, and there was no way for anyone to tell which. upsert_asset now attaches `<asset>.sha256` next to each asset, so verifying is `sha256sum -c punktfunk-1.2.3.dmg.sha256` in the download directory. Doing it in the helper rather than in the callers means all eight packaging workflows inherit it at once, and a future one can't forget. Sidecars rather than one shared SHA256SUMS: those workflows attach to the SAME release object concurrently, so a single manifest would be a read-modify-write race that silently drops whichever leg lost. One file per asset has no shared mutable state. The digest is over the file, but the name written into the sidecar is the ASSET name — callers rename on upload (Punktfunk-$VERSION.dmg), and `sha256sum -c` looks up the name it reads. The PowerShell twin writes the line byte-exactly (LF, no BOM): GNU sha256sum folds a trailing CR into the filename, so PowerShell's default CRLF would have failed every check on the box doing the verifying. Verified on both sides — bash and POSIX sh locally (`shasum -a 256 -c` passes), pwsh on the windows-amd64 runner (91 bytes, last byte 0x0A, no CR, no BOM, same digest as the bash path). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
80c0ca69fa |
fix(client/apple): take the accent wash off the scope menu too
ci / web (push) Successful in 58s
apple / swift (push) Successful in 1m27s
ci / docs-site (push) Successful in 2m58s
android / android (push) Canceled after 5m20s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m37s
ci / rust (push) Canceled after 5m44s
ci / rust-arm64 (push) Canceled after 5m42s
ci / bench (push) Canceled after 5m42s
deb / build-publish (push) Canceled after 4m56s
deb / build-publish-host (push) Canceled after 2m51s
deb / build-publish-client-arm64 (push) Canceled after 33s
decky / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 20s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s
release / apple (push) Successful in 20m41s
Same cause as the host grid's sort menu: a Menu draws its label — and the icons of everything inside it — in the accent colour, so a control whose only meaningful colour is the profile chips came out purple throughout. The chips are unaffected because they are rendered bitmaps rather than tinted symbols (see MenuIcon), so the colour that means something keeps it and the decoration loses its. The red trash stays red for the same reason. macOS only, as before — iOS menus already draw their icons in the label colour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d8252bb777 |
fix(client/apple): the sort menu was brand-purple beside two plain toolbar buttons
A Menu draws its label in the ACCENT colour where a Button draws it in the label colour, so this one came out tinted next to Add Host and Settings. macOS only: on iOS every toolbar item is accent-tinted already, and pinning this one to primary would make it the odd one out there instead of in step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2e7b4aea79 |
fix(client/apple): a pinned card belongs to its profile, not to its host's binding
Grouping by profile asked each HOST which profile it was bound to, then handed the answer to that host's every card. So a card visibly wearing a "Gaming" chip — a pin, on a host bound to nothing — was filed under "No Profile" along with the rest. Every pinned card was, which is most of what the grouping exists to separate. Pins are not bindings. The arrangement works on CARDS now: a host's own card follows its binding, a pinned card follows its pin, and both can land in the same band when a host pinned the profile it is also bound to. The expansion moved out of the view and into the arrangement, because it is exactly what the grouping has to see. The regression is pinned by a test whose fixture is the shape that broke: a host bound to nothing, carrying two pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f071467cbb |
feat(client/apple): sort and group the host grid
The grid had no ordering story — hosts sat in the order they were added and that was the whole of it. A toolbar menu now offers Sort by (Date Added, Name, Last Connected) and Group by (None, Profile, Status), both per device: this is a window on a list, not something about how a host streams. The control stays out of the toolbar until there are two hosts to arrange. Grouping by profile is the one the profiles made possible: bands in catalog order, each header wearing its profile's colour, then the unbound hosts. Pinned cards stay with their host — a pin is presentation of that host, not a host of its own — and a dangling binding lands in "No Profile" rather than in a band named after a profile that no longer exists. Empty bands aren't drawn. The ordering is pure and tested, which earned its keep immediately: the test caught that undated hosts were sorting first, and made me work out whether that was a bug. It isn't — no date means saved before `addedAt` existed, which means older, and in a real store those are a prefix, so the default sort leaves an upgraded grid exactly as it was. Two other traps are pinned by tests: `sorted` is not stable in Swift, so every comparison tie-breaks on the stored index or equal rows swap on redraw; and a never-connected host sorts LAST under Last Connected, not first as `.distantPast` would have it. `StoredHost` gains `addedAt` for the date the sort actually needs — optional and appended last, so the widget contract holds and hosts saved before it keep working. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
556ece4bc0 |
fix(client/apple): don't call a paid app "free software"
The term means liberty, but it is read as price — and this app is sold on the App Store, so the person most likely to read that line is the one who just paid for it. The licences are unchanged and the claim was true in its own sense; it was the wrong sense to leave ambiguous on a purchase. It now says what is unambiguously so: the SOURCE is open under MIT or Apache-2.0. Both copies (the form footer and the tvOS page), with a note on the wording so it doesn't drift back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9dcf943802 |
fix(client/apple): About stranded the licenses on iPad, and drew the icon unrounded
The iPad settings detail column is deliberately NOT a NavigationStack — an inner one doubles the title bar, which the code says right where it sets it up. I put a NavigationLink in it anyway, so opening Acknowledgements pushed into a context with no back button and left the license wall with no way out. It is a sheet on iOS now, the same one macOS already used; only tvOS still pushes, where the page really is inside a stack. And iOS hands over the app icon UNMASKED: the springboard applies the rounded shape at draw time, so used raw it is a hard-cornered square — the filled corners. It gets the squircle radius applied here. macOS is left alone: it bakes its own shape and margins into the image, and clipping that would cut into the icon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d2523a3b90 |
fix(client/apple): a tinted icon in a menu is a stencil, so the colour never arrived
Both the profile chips and the red trash failed for one reason: SwiftUI hands a menu row's icon to UIKit/AppKit as a TEMPLATE image, and a template is a stencil — the tint is discarded and the system fills in its own colour. `.foregroundStyle` on the label's icon was never going to survive, and the destructive role only colours the ROW (on iOS; macOS menus have no destructive styling at all), never the symbol. `MenuIcon` rasterises the icon first and marks the bitmap `.original`, which is not a stencil. The appearance goes into the render: `Color.brand` and the system reds are dynamic, and a renderer with no appearance resolves them to the light variant whatever the app is showing. Results are cached per icon and appearance — a menu rebuilds its rows on every open, and re-rendering a bitmap to draw a 12pt dot each time would be silly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8233722e9e |
feat(client/apple): the scope dropdown carries the colours, the icons, and Delete
Every profile row in the layer picker now shows its colour chip, so the dropdown is where you SEE the catalog rather than read it. It stays a Picker for that: the platform draws the selection checkmark in its own column, which leaves each row's icon free to be the chip. Hand-rolling the selection would have cost one or the other. Delete comes back to the menu it was taken from, next to New, Edit and Duplicate — all four with icons now. Its warning goes with it: the count of hosts that fall back and pinned cards that disappear is in the question, not discovered afterwards. The editor sheet drops its own delete section rather than offering the same destructive action in two places. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
586da1451c |
feat(client/apple): one sheet for a profile, with the colours visible
The create/update flow was four menu items, three bare text alerts and a submenu of colour NAMES with no colour anywhere on it. Making a "Work" profile meant: name it in an alert, find it again in the scope menu, open a submenu, and pick "Amber" on faith. A profile is a name and a colour, so they are decided together now — in one editor sheet that serves create, duplicate and edit, with a live chip at the top showing exactly what the host cards will render. The palette is swatches you can see, in a grid, with a checkmark AND a ring on the chosen one (the tick alone washes out on the pale hues) and a 44pt target under each 30pt dot. Default leads the row, so "no colour" is a choice on the same shelf as the rest rather than the absence of one. Duplicate opens the sheet rather than committing on the spot: it arrives carrying the source's colour and overrides with a free name filled in, so it can be renamed before it exists rather than after. Creating lands you in the new profile — being left on the defaults is how you end up editing the wrong layer. Delete moved into the sheet with the rest, and its warning is where the button is: the count of hosts and pinned cards that will change sits under it before you press it, not only in the confirmation after. The name's uniqueness check now reports inline and in red instead of through a disabled button and an alert message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f50d13752d |
feat(client/apple): an About page worth opening, and profile colours you can actually pick
About was a bare "Punktfunk / Version x" header over 885 KB of license text — it answered the one question nobody opens About to ask. It is now the shape every Mac user already knows: the app's icon, its name, the version (selectable, because a bug report is worth more with it), the tagline, and then the ways out — documentation, community, source, licenses. The license wall is still `AcknowledgementsView`, one push in on iOS/tvOS and a sheet on macOS, where a preferences tab has no navigation stack to push onto. Every platform hides the app icon somewhere different and tvOS can't hand it over at all (layered assets, no single image), so there's a drawn fallback in the same brand gradient and Geist monogram as the host cards — where the real icon is unavailable it reads as a mark, not as a broken image. tvOS also has no browser: the addresses are text to read off the screen rather than links to nowhere. And profile colours were half-built by me: the catalog stored `accent`, the chips tinted from it, and nothing in the app ever SET one. The scope menu grows "Color ▸" over a fixed palette — the chip is small tinted text on a tinted capsule, and a colour picked freehand lands somewhere unreadable often enough to matter. The palette is what this client OFFERS, not what it accepts: any `#RRGGBB` another platform writes still renders, which is what Android needs from this field. The colour now shows wherever the profile is named — the scope switcher's dot, the card chips, and the HUD's session line, which tints to the same colour the card that launched it wore. A colour is an identifier, and an identifier that changes between surfaces isn't one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8b17e72af0 |
fix(client/settings): one marker per override, footers at footer size, air under Reset
The resolution marker hung off the Match-window toggle, which read as if that toggle alone were overridden. Match-window, width and height are ONE override — they reset together — so the marker belongs to the group, under the size control that ends it, not to the first of the two rows that write it. The new section footers inherited the app's 17pt body font, so the profile picker's description sat visibly larger than every other description in Settings. Same style as the footers that were already there. And the marker carries a bordered button, which needs more air under the control above it than a line of text does — most visible under the segmented refresh picker. Added inside the marker, so every row that shows it is spaced alike. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |