907985a0b9c730edaf20bd00c1205e54bcd0b9bd
528
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
e9abc1a61f |
fix(client/settings): Reset belongs on the row's edge, not in the caption's column
The override marker sat inside the caption's width rule, so its Reset stopped where the text stops — stranded mid-row, short of the switch it undoes. The rule is for TEXT: Reset is a control, and it lines up with the row's own control above it. The marker now spans the cell and only the caption is capped and inset. The caption's reserved column widens with it (60 → 76): it should stop visibly short of the control, not graze it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c1d2efe112 |
fix(client/apple): the Name field didn't say it was the name either
Same cause as the MAC one, and it deserved the same fix rather than another one-off: on iOS a TextField's title becomes an accessibility label the moment a prompt exists and nothing is drawn, so "Optional — e.g. Living Room" was the whole of what the field said about itself. The prompt is now built per platform. macOS draws the title as a leading label, so its prompt only hints at the value; iOS has no label to lean on, so the prompt names the field too. One string for both leaves you either a Mac panel that says everything twice — which is what the MAC field started doing in the last commit — or a phone form of unlabelled boxes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
34a9e32bf8 |
fix(client/apple): put the add-host sheet back, and fix the MAC field where it's read
The footer was the wrong instrument and I should have checked what the screen actually shows before adding one. On iOS a field's title becomes its accessibility label once a prompt is given, so "MAC" was never on screen — the PROMPT was, and "Wake-on-LAN — auto-filled when known" read as if that were the value being asked for. It now says what the field is: "MAC address (for Wake-on-LAN, optional)". No explanatory paragraph under the group. Height and scrolling go back to what they were: a 392pt sheet sized to its content with nothing to scroll. The edit sheet's profile rows are the only thing that can outgrow that, and only they extend the detent and turn scrolling back on — a single fixed number is what clipped them in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
282d691b76 |
fix(client/apple): give the add-host sheet back to adding a host
Adding a host is about reaching it: where it is, and whether we can wake it. Which settings it streams with is a decision about a host you already have. Stacking the profile binding and the pin toggles onto the add flow made the first thing a new user meets a longer form than the one they came for — those rows are edit-only now, and editing is one context-menu item away. The pins lost their disclosure with it. A collapsed group had to animate its own height AND the sheet's, and got both wrong — no transition, then a clipped list. With a profile or three these are a couple of rows, and rows that are simply there can't fail to expand. "MAC" named the value and said nothing about why the field is there, while "Wake-on-LAN" sat in the placeholder as if it were something to type in. The label is "MAC address" and the section footer says what it buys: waking a sleeping host, filled in by itself once the host has been seen. Same relabelling on tvOS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1d51d83acf |
fix(client/apple): the add-host sheet was Mac-sized on iPhone, and clipped its own pins
Two things, one cause: the sheet was written as a Mac panel and iOS inherited it. The 12pt Geist and `.controlSize(.small)` exist because a grouped form's system text reads oversized next to the app's typography in a Mac panel. Applied to iOS as well, they made this the one sheet in the app you had to squint at — a touch target and a field label there are not a Mac panel's. macOS keeps them; iOS keeps the app's body size. And the sheet's height was a single hardcoded number with scrolling switched off, so expanding "Pinned cards" grew content into a height nobody recomputed and the toggles were simply clipped — the disclosure looked broken because there was nowhere for it to go. The height now follows what the sheet is showing (base fields, the picker when profiles exist, the toggles while expanded, which is why the disclosure's expansion is bound state now), scrolling is back on so nothing can be stranded, and `.large` is offered alongside for the accessibility text sizes these estimates won't cover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cf55a11174 |
fix(client/settings): captions ran under the switch on iPhone
The `described` idiom capped its caption at 360pt, which was only ever about READING — past roughly 46 characters a line stops scanning well on a wide Mac window or an iPad detail pane. On an iPhone the cell is narrower than the cap in the first place, so the cap did nothing and every caption laid out to the full cell width, running its last line straight under the row's switch. `CaptionWidth` now carries both limits: the reading cap, plus an iOS trailing inset that reserves the control column (a `UISwitch` is 51pt, so 60 with room). Order matters — the inset shrinks what the text is offered, then the cap applies, so a narrow phone cell clears the switch and a wide pane still caps. One rule in one place: the override marker and the iOS resolution wheel's caption each carried their own copy of the 360, and neither would have grown the inset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9385b61ac2 |
fix(client/apple): the scope switcher was a Mac control dropped into an iPhone list
One chrome served both platforms and only worked on one. In the iOS settings list the macOS shape — a bordered `.menuStyle(.button)` with the caption stacked under it — rendered as neither a row nor a control: icon only, no label, and four lines of wrapped caption making the first thing on the screen a block of text. The menu CONTENTS stay one definition; the chrome is now per platform. macOS keeps the button menu heading the preferences window. iOS gets a standard value row — "Editing" on the left, the current layer on the right, the system's up/down chevron — with the caption where a caption belongs in a grouped list: the section footer. The two prompts the menu can raise moved into a shared `profilePrompts` so both chromes carry them without a second copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
afb33d510f |
fix(client/apple): anchor the profile chip to the card's trailing edge
On the title line it sat immediately after the host name, so it read as part of the title rather than as a badge on the card. A spacer between them anchors it to the trailing edge, and the text column now fills the card — without that it hugs its content and "trailing" only ever means "just after the name". The spacer is also what keeps the two apart: the host name truncates against the gap instead of running into the chip, and the chip keeps layout priority because a truncated host name still reads while a truncated profile name is the one thing the chip exists to say. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
82f5962bec |
fix(client/apple): three things the first on-glass run found
Reset read as prose. Next to "Overrides Default settings" in the same tint and the same size, the one action that can undo an override looked like the third word of the notice. It is a bordered control now, pushed to the far edge of the caption line with an undo glyph. The scope switcher showed above the About tab. It sat over the whole macOS TabView, so on the acknowledgements page — which edits nothing — it read as belonging to them. The tabs are tagged and the switcher sits that one out. Pinned cards were taller than their host's. The profile chip had a line of its own, so a card with a profile and one without were different heights and a pin stuck out of its grid row. The chip rides the title line instead, which is also where it reads as "this card connects with that". Prominence is fill and weight only — a chip with a bigger type size would have brought the height difference straight back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0a32f4eab1 |
fix(client/apple): the iOS slice hit the type-checker wall, and tvOS couldn't be checked at all
`ContentView.body` grew two modifiers and stopped type-checking on the iOS slice — "unable to type-check this expression in reasonable time", a failure macOS builds never show and the one Apple CI never builds. Split into the screen plus its lifecycle drivers, then the prompt chain, with each alert's presentation Binding lifted out the way the deep-link one already was. tvOS couldn't be checked from the command line at all: HomeView imports the slide transition, which ships with the Xcode project only because its manifest breaks SwiftPM's whole-graph validation. `canImport` instead of a bare `os(tvOS)` gate makes the tvOS sources compile without it, and the app still gets the transition because there the module is present. Both slices now typecheck by hand. The scope switcher is tvOS-gated with them: a name prompt and a nested management menu are not what a remote does well, and §5.4 keeps profile EDITING off controller-first surfaces in v1 — they honor bindings and render pinned cards. Also: release the session's settings latch when a connect fails, is refused, or is abandoned mid-handshake. Only `disconnect` cleared it, so a failed dial left the would-be session's resolution latched over the globals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
25b127803f |
feat(client/apple): bind a host to a profile, connect with one, pin one as its own card
The host surfaces (design §5.2/§5.2a). A bound host wears a tinted chip that says what a click will do. The card menu grows "Connect with ▸" — a ONE-OFF that never rebinds, with a checkmark on the binding and an explicit "Set Default Profile" as its last item for the users who do want to rebind from there — plus "Pin as Card ▸" and "Copy Link". A pinned host+profile combo becomes its own card next to its host: same record, same live status, the profile as the prominent subtitle, one click to connect. It is an extra grid ENTRY, never a duplicated host record — duplicating would fork pairing, Wake-on-LAN and renames. Its menu carries only connect-shaped actions; edit, pair, forget and remove stay on the primary card, where the thing they act on lives. On the gamepad carousel and tvOS the same pins are tiles, which is the whole point: focus-and-press is what those surfaces do well, and menus are not. The edit sheet binds and pins; both rows vanish when no profiles exist, so a user who never makes one sees exactly today's sheet. A dangling binding renders as "Default settings (profile deleted)" and is cleaned up on save. The speed test finally writes where the tested host reads. Unbound: the global, as before. Bound to a profile that overrides bitrate: that override. Bound to one that inherits it: both are offered, because either is defensible and guessing would silently pick one. Every button names its target, and the probe now runs at the mode that host would actually stream — the measurement is the streaming path. Shortcuts gains `ProfileEntity` over the App-Group catalog and a Profile parameter on Connect, still round-tripping through the URL router rather than opening a second connect path. Connect and Wake drop their iOS wall — AppIntents is real on macOS and tvOS, and "Stream Desktop with Work" from Spotlight was the ask; only the phrases provider stays iOS-gated, since it bundles the LiveActivityIntent. The deep-link observer moved out with them: an intent that posts to nobody is a shortcut that silently does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3ae9b83290 |
feat(client/apple): the settings screen edits profiles, and says which rows it changed
One settings surface, two layers. A scope switcher at the top of the Mac window and the iOS sidebar swaps the whole screen between Default settings and one profile's overrides; the section builders stay the single definition of every row and just change which layer their binding reads and writes (design §5.1). A parallel profile editor would have drifted from this one field by field, and the revamp's captions and curation would have had to be written twice. `SettingsFields` is where a row's three faces meet — the UserDefaults key its global lives under, the overlay slot an override lives in, and the name a reset carries. Keeping them in one place is what stops a row from writing an override the reset button can't find. Every row shows the EFFECTIVE value, so an untouched row reads as the live global. Touching a control records the override — always, even when the new value equals today's global, because that is a pin and the profile must keep it when the global later moves. Nothing is inferred by diffing at save time. Overridden rows say so (accent dot + "Overrides Default settings") and carry the only way back: an explicit Reset. That pair is not garnish. The model deliberately never infers "not overridden" from a value comparison, so without a reset affordance a profile is a one-way door. Tier-G and tier-H rows don't render in profile scope at all — this device's speaker, microphone and channel, its pointer capture and haptics, the HUD corner, the library switch, the gamepad-UI switch, which physical pad is forwarded, and auto-wake, which is about a host and a network rather than about Game vs Work. Sections that would be left empty (Session off macOS, Library) collapse instead of rendering a bare group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
858cf0e535 |
feat(client/apple): profiles, resolved once per connect instead of ten times mid-session
The Apple half of settings profiles (design/client-settings-profiles.md §4) starts with the thing the desktop shells got for free from the shared Rust core: a model, and one place that resolves it. `SettingsOverlay`/`StreamProfile`/`ProfileCatalog` mirror `pf-client-core::profiles` field for field, unknown-key carry-through included — an older build that opens a profile a newer one wrote must not gut it on save. The catalog lives in the App Group suite beside the saved hosts, because the things that point at it are fields on the host record: `StoredHost` gains `profileID` and `pinnedProfileIDs`, both optional and appended last, because that JSON is a widget contract. `EffectiveSettings` is the resolution — globals with the session's overlay on top, computed once at connect and latched in `SessionSettings` for the rest of it. That latch is the point. Ten sites across the app AND the kit read `UserDefaults` directly mid-session (the presenter's priority and VRR, the vsync flip, the match-window follower, the scroll sign, the touch model, the mouse model, 4:4:4, the audio endpoints); a profile that reached some of them and not others would be worse than no feature at all. They now read the latch, which off-session is the plain globals — byte for byte what they saw before. The deep-link grammar grows up with it: `DeepLink` becomes a full port of `deeplink.rs` — routes, host-ref forms, `fp`/`host` recovery, one-off `profile`, and every refusal code — and the Swift suite now runs `clients/shared/deeplink-vectors.json` itself, read from the source tree so there is no second copy to drift. All 44 cases green. The router refuses what it can't honor by name: a profile that doesn't exist, an ambiguous one, a fingerprint that contradicts the pin. A shortcut that streams with the wrong settings is worse than one that explains itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |