abc6d790bde4ecab0954576a73fda49efb091eab
237
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
92f617a989 |
Merge remote-tracking branch 'origin/main' into worktree-haptics-m12-dry
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m58s
ci / rust-arm64 (pull_request) Successful in 5m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 8m47s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m10s
# Conflicts: # clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt # crates/pf-client-core/src/gamepad.rs |
||
|
|
2f071a9a93 |
Merge pull request 'fix(clients/settings): controller settings that can't do anything no longer look live' (#50) from worktree-haptics-m11-settings into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 59s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
|
||
|
|
68353a5d57 |
Merge pull request 'fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying' (#32) from worktree-haptics-m4-apple into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 53s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
|
||
|
|
42a0dd52be |
refactor(haptics): one copy of each thing every rumble path was transcribing
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 9m44s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found real defects hiding behind the duplication. **The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim copy of the kernel's constants plus its own `put_cstr`, and they had already drifted: `switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a bug in both directions — a longer report was truncated, and a shorter one had the parser reading whatever the reused event buffer still held past the payload, i.e. acting on rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the two accessors that are easy to get subtly wrong, with tests on exactly that. **A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1` branch could never run — and allocating from a local counter would have been the wrong answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where it stood. **Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has always floored it at 1; this was the odd one out. That converter also existed twice byte-identically inside one Gradle module — now one `wireAmplitudeToByte`. Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`) documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it and `pf-inject` do not depend on each other, and a DualSense layout has no business in `punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes the struct it describes instead of a second literal 11 — the header now emits `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms` and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets `min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served by the quirk, because that renderer skips writes whose levels are unchanged and would swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is marked as staged scaffolding (the tag is on a shipped wire; removing the variant would not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine, both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides, with `MAX_PADS <= 16` now a compile-time assertion rather than a comment. Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12). S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs` calls `set_forwarding` unconditionally on every params-build (its own comment explains why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI cycle each launch. Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit: 6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails the pin or refuses to compile. |
||
|
|
9fb41affba |
fix(clients/settings): a controller setting you can't use no longer looks like one you can
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / web (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 8m4s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m30s
Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised them, the touch settings on both mobile clients dimmed them and the console UI refused the step; the Windows client and BOTH controller-navigable screens left them fully live, so you could sit there changing settings that did nothing. Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type, guide button and hold-Select rows — the same builder the echo-cancellation row already used to follow the mic switch. Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)` / `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change" on a dimmed row, because advertising them was the same lie the live row told. Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" — but it only faded the label: every dimmed row still stepped and still wrote its setting. The "No profiles yet" placeholder looked inert only because its own closures were empty. Made it real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap on the already-focused row), then gated the pad rows on it. Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings have carried beside its SC2 twin all along. It was missing exactly where it matters most — a TV box has no touch interface to fall back to, so there was no way to reach it at all. Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's system gesture and powered the controller's IMU. Neither reaches the host, so the first only took the user's screenshot/Home gestures away for nothing and the second drained the pad's battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's own four buttons keep their claim. A test pins the alias list against the chord mask; if they drift the symptom is a session nobody can leave, with nothing logged. Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and "Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen was not — both corrected here. Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean + full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt --all --check clean. Each fix probed by reverting it — every probe failed the tests it should. |
||
|
|
d7e22c3db2 |
Merge pull request 'fix(apple/shots): the store screenshots show the app as it actually is' (#48) from worktree-apple-store-screenshots into main
apple / swift (push) Canceled after 15s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
Reviewed-on: #48 |
||
|
|
1db7058a5d |
feat(clients/input): system buttons route around local overlays
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26 opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for one press — the local one covering the stream. Two cross-client tier-P settings, zero wire changes: - system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the same physical press no matter what. - guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's guide, down until release — held on, that's the host's long-press, which opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered on release with its up TAP_PRESS (50ms) behind, because per-transition sends fold into seq'd GamepadState snapshots and a back-to-back pair can coalesce into no press at all. A Select inside a combo (the escape chord) passes through untouched. Auto arms it only where the raw press can't reach the host cleanly: gamescope, iOS/iPadOS, tvOS. The same SelectGesture rules live in pf-client-core (pure state machine + unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and Android's GamepadRouter. Settings rows on every surface (GTK, WinUI, console UI, Decky, Apple x2, Android x2) with profile plumbing throughout. punktfunk-session grows a control socket ($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one runtime path a flatpak and the host see identically): 'guide'/'qam' verbs inject synthetic taps. The Decky panel gains a Host menus section (visible while the client runs) whose buttons press the host's Steam/QAM and close the local menu so the host's shows through. iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the class needs the Xcode 27 SDK to compile). Docs: input, client-settings, steam-deck. Design: punktfunk-planning design/system-buttons-routing.md. Gates: docker clippy --all-targets --locked -D warnings + tests (pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47), cargo fmt --all --check, swift build (macOS), gradle kit+app compile, decky tsc --noEmit + py_compile. clients/windows not compiled (no box). |
||
|
|
7b1554af4b |
fix(apple/shots): fill the grid, open Settings on Display, note the iPad orientation limit
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 2m34s
ci / rust (pull_request) Successful in 7m6s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
- Six mock hosts rather than three. An iPad-13 portrait grid is three columns wide and 2752 px tall; three cards left ~60% of the capture as black. - Settings opens on Display, not General. Resolution, frame rate, bitrate, HDR and codec are what someone reads a streaming app's settings shot for. - The wake scene is the modal-over-grid variant. The gamepad-UI one is a full-screen takeover over a bare gradient — correct, but four lines of text on an empty aurora; the modal shows the same overlay over the host grid. - `requestGeometryUpdate` now reports a refusal instead of failing silently. It does not help on the simulator (an app's stdout doesn't reach the driver through `simctl launch`) but it will on macOS and on a device. - Documented that `.landscape` does not rotate on iPad: a multitasking-capable iPad app is resizable, so iPadOS ignores the request and simctl cannot rotate a simulated device. The iPad set is portrait throughout. |
||
|
|
8f35155c14 |
fix(apple/shots): the store screenshots show the app as it actually is
Uploading the 0.24.0 set surfaced a pair screen that reads as broken, and a hero that was never the orientation it claimed. The capture harness: - Landscape scenes were captured in PORTRAIT. `IOSOrientationConfigurator` asked for the geometry update from `updateUIViewController`, where `view.window` is still nil — SwiftUI makes one update pass for a `.background` representable, before the hierarchy is in a window, so the guard fell through and nothing ever asked again. Both `.landscape` scenes (the stream hero, the trust card) shipped as portrait. Now a real UIViewController asks from `viewDidAppear` and pins `supportedInterfaceOrientations`. - The shot host applied `.ignoresSafeArea()` to the whole scene, so the hero's HUD — resolution, bitrate, the latency breakdown, the entire point of that screenshot — sat under the Dynamic Island. Only the black backing ignores it now; scenes that want full bleed already ignore it themselves. - `03-pair` was hand-composed into a ZStack rather than presented. PairSheet is a bottom sheet on iOS: its detents and the system's Liquid Glass only exist inside a real `.sheet`. Composed, the grouped Form stretched to full screen height and the capture was a strip of content over a black void, with a DISABLED "Pair & Connect" (empty PIN) and the capture simulator's own name — `pf-shot-iphone-6.9` — rendered in as the device name. - Sheets do not inherit `.environment(\.colorScheme, .dark)` across the presentation boundary; they follow the DEVICE. The pairing sheet came out light grey over the dark app. The simulator is now set to dark appearance. - Discovery browsed the live LAN mid-capture, so a bystanding machine's hostname went out on the listing and no two runs matched. `HostDiscovery` gains a `debugSet` seam (the counterpart to `HostWaker.debugSet`); the mock hosts advertise, so cards read ONLINE through the real `advertises` path and the reachability probe never touches the network. - Created simulators were named `pf-shot-<prefix>`, which the reuse regex never matches: every run created another simulator and none was reused. They are named after the device now — reusable, and not user-visible junk. Two bugs found on the way, neither screenshot-only: - HostStore/ProfileStore PERSISTED the harness's mock data. On a dev Mac that is the same App-Group suite the real app reads, so running the script could replace the tester's saved hosts with "Battlestation" & co. - GamepadHomeView drew the controller chip as a trailing `.overlay`, which reserves no width — on a portrait phone it sat on top of the centred "Select a Host". Laid out as a row with a hidden leading mirror. - The pairing sheet's field prompt said "How the host lists this Mac" on iPhone and iPad. Coverage: the listing set is six scenes in listing order, and is now the stream, the machines it found, the couch/controller mode, waking a sleeping host, the quality controls and pairing — the console and wake screens already existed in `ShotScenes.all` and were simply never captured. Mock hosts carry OS marks, Wake-on-LAN MACs and profile chips so the grid is full rather than three offline rows over an empty half-screen. `SCENES=` overrides the set for the dev scenes. |
||
|
|
454fa2e0cb |
Merge pull request 'feat(gamepad-ui): profiles integration — pinned cards, pin management, settings section on all three gamepad UIs' (#42) from worktree-gamepad-ui-profiles into main
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13s
deb / build-publish-host (push) Successful in 4m30s
android / android (push) Successful in 7m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
deb / build-publish (push) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 5s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m37s
release / apple (push) Successful in 9m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m55s
ci / rust (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m19s
docker / deploy-docs (push) Failing after 9m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m30s
apple / swift (push) Successful in 1m32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / web (push) Successful in 1m8s
flatpak / build-publish (push) Successful in 27m16s
ci / rust-arm64 (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
ci / docs-site (push) Successful in 1m51s
deb / build-publish-client-arm64 (push) Successful in 1m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
Reviewed-on: #42 |
||
|
|
80b4eccff9 |
feat(apple/gamepad): Profiles section + pin picker in gamepad settings
GamepadSettingsView gains a trailing Profiles section (one row per catalog profile, live pinned-to-N-hosts counts) and an in-place pin-to-hosts picker driving HostStore.setPinned — the first pin management reachable from the controller-first UI, and on tvOS the only possible one. tvOS wording drops the 'create them in the standard interface' promise (no profile editor exists there); other platforms keep it. Pinned-card rendering and the connect path were already in from WP5 and stay untouched. |
||
|
|
7331be0a40 |
Merge pull request 'fix(audio): the quality root cause, the latency ratchet, and making the plane observable' (#33) from worktree-audio-quality-latency into main
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 2m31s
ci / docs-site (push) Successful in 1m19s
deb / build-publish-client-arm64 (push) Successful in 3m11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m38s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m58s
deb / build-publish-host (push) Successful in 6m0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
android / android (push) Successful in 7m13s
arch / build-publish (push) Successful in 8m1s
ci / rust (push) Successful in 10m0s
apple / screenshots (push) Canceled after 0s
deb / build-publish (push) Canceled after 8m16s
docker / builders-arm64cross (push) Canceled after 9s
docker / deploy-docs (push) Canceled after 42s
release / apple (push) Successful in 9m17s
flatpak / build-publish (push) Canceled after 4m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m48s
windows-host / package (push) Canceled after 5m27s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #33 |
||
|
|
6f54fcdd2d |
fix(client/ios): a click wins the pointer back after Escape drops it
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / rust (pull_request) Successful in 13m38s
Pressing Escape mid-stream on an iPad leaves the capture in a state it could never leave: iPadOS releases the pointer lock by itself, a bare Escape deliberately never clears `captured` (it is a game key), and the re-lock burst added with the Escape-drop fix is the only thing that ever asks for the lock back. That burst fires in the 0.6 s immediately after the platform's own "let me out" gesture — precisely when it is least likely to be granted — and once its budget is spent nothing re-asks: `setCaptured` is the only other requester, and `captured` never went false. The capture then spends the rest of its life on the absolute pointer path, which is why the field report reads the way it does — clicks still land exactly where you aim, because absolute positions keep forwarding, but the game receives no relative deltas and camera look is dead for the rest of the session. Make the click the second stage of the recovery. A click into the video while captured-but-unlocked now re-anchors the lock chain and re-asks, which is the request the platform actually wants: a genuine user gesture rather than an app grabbing the pointer straight back. Asked on the button UP, so the click has fully forwarded on one transport first — asking on the DOWN can flip `gcMouseForwarding` mid-click and strand the release on the GCMouse path. Gated on `pointerLockWasEngaged`, exactly as the drop path is, so a scene that never qualifies (Stage Manager, Split View) is never bursted at, and on no burst already being in flight, since a pending burst mutes absolute motion and re-arming one per click would freeze the cursor between clicks of a menu the user is still aiming around. Worst case is now today's behaviour rather than a permanent one: a refused burst settles, and the next click tries again. Typechecked for arm64-apple-ios17.0 (PunktfunkKit builds clean). NOT yet verified on glass — the premise that a click-driven re-request is honoured is exactly what the previous fix got wrong. |
||
|
|
2cfc82e96c |
fix(audio): budget the audio plane against the link, and close the review's gaps
Findings from the post-implementation review of design/audio-quality-and-latency.md. **The bandwidth gap (highest).** Tier `High` (256 kbps) and the redundant `0xD2` plane were added separately, each costed as "~1 % of the video budget", and nobody added them together: 256 kbps sent twice is 512 kbps — ~2.5 % of a 20 Mbps session but ~10 % of a 5 Mbps one. Audio rides QUIC datagrams, OUTSIDE the ABR loop, so ABR could neither see that nor reclaim it; a constrained link quietly handed a tenth of its bandwidth to audio while ABR carefully managed the rest. `plan_audio_budget` now makes tier and redundancy ONE decision against the session's resolved video bitrate, ordered by preference rather than cost — transparent audio beats redundant audio, since the field report was about quality and redundancy only pays under loss, so `High` alone outranks `Standard`+redundancy even though they cost the same. It can lower what the operator asked for, never raise it, and never goes below `Low`: a stream with unintelligible audio is worse than one spending a few percent more. **The Linux host kept the exact defect fixed on Windows.** `let _ = tx.try_send(samples)` — silent, uncounted data loss, where the encoder concatenates across the hole, so every drop is a click AND a permanent shift of everything after it. WP0.2 turned out to be Windows-only and had not said so. Linux now shares `capture_policy::CaptureStats`: drops counted and warned, plus per-window peak/RMS/delivered%. A Linux audio report was until now exactly as un-triageable as the Windows one was on 2026-08-03. **Apple's WP0.3 was half-done** — `bufferedMS` was added and wired to nothing. The drain thread now logs buffer/target/underruns/sheds like the other three, from one locked snapshot so the numbers in a line describe the same instant. Also: the Linux "audio format negotiated" line now says WHICH mode produced it, because that changes what it is worth — in stream-sink mode the host owns the sink so the mix cannot have been narrowed upstream, but in legacy monitor mode a 16 kHz Bluetooth sink would still be reported as a clean 48 kHz through PipeWire's resampler, the same way WASAPI's autoconvert hid it on Windows. Reading the monitored node's own rate needs a registry lookup this stream does not do; recorded as an open gap rather than implied to be covered. Two stale docs: `audio_wasapi.rs` cited `clients/windows/src/audio.rs` (deleted) and still described the pre-shared-policy "prime to ~3 quanta" behaviour. And the Apple ring's `prefill:` parameter, dead since the depth moved into the ring, is gone. Verified: clippy --all-targets -D warnings on Linux (docker) AND Windows (runner .133, forced clean rebuild of punktfunk-host + pf-client-core); core 167 tests; host 57 audio tests on Windows; Android clippy count identical to pristine (6, all documented arm64 artifacts); Apple ring re-simulated. The host suite's `gamestream::stream::tests::sender_delivers_batches` fails under qemu — the recorded environmental flake, unrelated to audio, green on the earlier less-loaded run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a12f1f092c |
feat(clients/audio): one de-jitter policy for all four rings, and lossless single-packet recovery
Phase 4 + WP3.2 of design/audio-quality-and-latency.md. **The defect.** Every client ring primed *up* to a target and clamped at a ceiling, and none walked the depth back *down*. Any transient — a Wi-Fi arrival burst, a host stall, or plain host-DAC-vs-client-DAC skew of a few dozen ppm — therefore added latency permanently, until an underrun happened to re-prime. Android, with no shed at all, converged on its 120 ms hard cap and stayed there for the rest of the session; that is the "audio latency is too high" report. Apple did shed, 40 ms in one go, which its own comment called "one audible blip". All four now share `punktfunk_core::audio::JitterPolicy`: depths in MILLISECONDS rather than device quanta (`3 x quantum` meant 15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms one), a crossfaded 5 ms shed once the depth average has sat above target for 2 s of consumed audio, and de-prime hysteresis. Linux and Windows had never had that hysteresis — they still carried the `if ring.is_empty()` instant re-prime that Android identified as self-inflicted crackle, where one transient drain manufactured a whole target's worth of silence. Android's floor drops 40 -> 25 ms: the policy grows the target on the devices that actually underrun, instead of every device pre-paying for the worst one. The Windows ring moves from raw bytes to interleaved f32 so it can share the policy and the crossfade helper at all. Apple is the one client where the policy is hand-written in a second language, so it gets its own XCTest (`AudioRingDriftTests`). Verified here by compiling `AudioRing.swift` standalone against a simulation harness — +200 ppm for 5 minutes settles at 30 ms with zero silent callbacks, where the old ring would have ridden its 80 ms high-water mark. **WP3.2 — recovery lives in core, not in the clients.** The rebuilt frame is re-inserted into the demux queue in order, so every embedder (including any C-ABI consumer) gets a complete stream without knowing the `0xD2` plane exists, and their `AudioGapTracker` simply stops seeing the gap. `recovery_and_the_gap_tracker_agree` pins exactly that. For the same reason core advertises CLIENT_CAP_AUDIO_RED itself rather than making four embedders remember to. Verified: clippy --all-targets -D warnings and the full test suites for punktfunk-core, pf-client-core, punktfunk-host, pf-host-config under Linux/docker (163 + 61 tests); punktfunk-client-android `cargo ndk check` for aarch64 with the gate proven non-vacuous by a planted type error, and its 6 clippy findings confirmed IDENTICAL to the pristine file (all are the documented arm64-only artifacts); AudioRing.swift type-checked and simulated on macOS; fmt. The Windows client half (audio_wasapi.rs) is still not compile-verified anywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
76832a5b86 |
fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying
apple / swift (pull_request) Successful in 1m25s
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / rust (pull_request) Successful in 7m11s
Five faults in the Apple client's feedback path. With two DualSenses attached, each pad's renderer opened "the first connected DualSense" — taken from an unordered Set, so the choice could differ between two calls in one process. Both renderers could land on the same device, one pad's rumble coming out of the other while their per-instance write dedupes fought over it, or they could split by luck. Each renderer now asks for the device its own controller is, correlating GameController's stable ordering with IOKit's location ids; the selection rule is a pure function so it can be tested without an IOHIDDevice, which cannot be constructed. Without a preference the lowest location id wins — still arbitrary, but stable, which Set.first was not. A failed HID write was logged and swallowed, so a write that never reached the device still counted as a successful render. That matters most for a stop, which has nothing behind it: the renderer stamped its write clock even on failure, the keepalive only re-writes non-zero levels, the ticker is cancelled once the target is zero, and on USB there is no firmware timeout. A swallowed stop therefore left the motors running with nothing scheduled to try again. The write result now reaches the caller, which drops the handle and falls back to CoreHaptics rather than claiming success. A half-failed split-handle setup reported HEALTHY. Only the all-nil case counted as failure, so one surviving handle passed silently while rendering something wrong in a direction that depended on which handle died: lose the right one and render falls to the combined branch, playing max(low, high) on the LEFT handle; lose the left and the split branch discards the heavy motor outright. A half-open split now tears the survivor down and takes the combined path, which at least renders both motors somewhere. Session end never put the lightbar out. This class is what turned it on, and every DS write is valid-flag-selective, so a game's last colour stayed lit in firmware after the stream ended — a DS4 was cleared incidentally because its player indicator IS the lightbar, a DualSense was not. And the renderer's stop() ran on the main actor. It is a queue.sync whose body is a per-motor CHHapticEngine.stop() — an XPC round trip the renderer's own notes record as able to hang — plus a blocking HID write to a device that has just departed, and it queues behind any in-flight setup(). It runs on every unplug and every pin change, and the main thread drives the presenter's CADisplayLink, so it hitched the picture mid-stream. It is detached now; the renderer is already off routing by then, so nothing observes it. Verified: swift build clean, 188 tests pass (185 before), and the three new device-selection tests fail if the deterministic fallback is reverted. Note for anyone rebuilding here: the checked-in xcframework was stale (it predates punktfunk_connection_report_phase) and build-xcframework.sh still dies on this Mac at its macOS-floor guard. A macos-arm64 slice assembled by hand from `cargo build --target aarch64-apple-darwin` is enough to typecheck. From the 2026-08-03 force-feedback sweep (B14, B15, B18, B19, B20). |
||
|
|
b297542c4d |
feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m27s
ci / rust (pull_request) Successful in 7m59s
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a pad plugged into the host — arrived there twice: once as the real device, once as the virtual pad this client built from the same hands. Games read both, so a stick drifts against the centred second pad and menus take every input twice. New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P, so a profile can decline what another profile forwards. On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a passthrough tool cannot bind, so with this off the session opens no slot at all and never enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free for the whole time a stream is up. The consequence, documented at both the setting and the chord: the controller escape chord is read off forwarded pads, so it is unavailable there. The Apple and Android input stacks claim nothing, so those clients keep their slots and their chords and only gate the wire sends — losing tvOS's only controller way out of a stream would have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures, which do claim the device. Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off. Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui, punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both Windows boxes were offline; its edits were reviewed against the helper signatures by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d63e913f52 |
fix(client/ios): Escape keeps the pointer captured instead of handing it back to iPadOS
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m26s
ci / docs-site (pull_request) Successful in 3m46s
ci / rust (pull_request) Successful in 4m8s
iPadOS releases the scene's pointer lock by itself when Escape is pressed — the platform's built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing in our code does it: a bare Esc never touches `captured`, and it keeps forwarding to the host as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path and un-hides the iPadOS cursor, so pressing Esc for an in-game menu silently cost the capture until the user clicked into the video to win it back. Esc is a GAME key in a stream, not a request to hand the pointer back to iPadOS, so an unwanted drop is now re-requested. `syncPointerLock` arms a short, bounded burst (3 attempts over ~0.6 s, no restart inside 2 s) whenever the lock is wanted, was previously HELD, and is now gone; the first attempt re-asserts `prefersPointerLocked`, later ones present a real false→true transition and re-anchor the PointerLockChain. Every deliberate release (⌘⎋, ⌃⌥⇧Q, the Stream menu, resigning active) clears `captured` first, so `wantsPointerLock` is already false when their drop is observed and none of them are fought. The "previously held" half of the condition keeps a scene that never qualifies (Stage Manager, Split View) from paying for a lock that isn't coming — there, a first grant is still driven by the chain engage in setCaptured/viewDidAppear exactly as before. While a re-lock is in flight the local cursor stays hidden and absolute pointer MOTION stays muted, so the couple of frames it takes read as "Esc did nothing to my mouse" rather than a cursor that blinks in and out and a host cursor that teleports to the pointer's absolute position. Buttons still forward (they carry no position), so a click mid-relock isn't swallowed. The burst clears itself on give-up, so the cursor can never stay hidden on a lock the system won't grant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
766991cf6a |
feat(apple): the mic has an off switch you can reach mid-stream
Until now the only way to stop sending the room was to end the session and turn "Send microphone to the host" off in Settings — a per-app setting for something that is really a per-conversation act. The mic now mutes from inside the stream: a button on the HUD card, the Stream menu's ⌃⌥⇧A (macOS menu bar and iPad hardware keyboards), the same chord while input is captured (both platforms detect it in InputCapture, where the reserved ⌃⌥⇧Q/D/S already live — ⌃⌥⇧M is the mouse-model flip, cross-client, so the mic gets A), and on iPhone/iPad a mic disc beside the touch exit for the stats tiers whose HUD carries no buttons. Muting is local and instant: it gates capture on this device, the host is never asked and never told. The muted state gets its own badge over the stream — independent of the stats overlay, because "am I muted?" is not a statistic and the overlay is exactly what a player turns off. The badge is also the way back: tapping it unmutes, which is the guaranteed path for a touch user who muted with the overlay off. Mute is session state and is deliberately not persisted. Every stream starts live if the mic is enabled at all, rather than carrying a mute nobody remembers making into a call three days later. The mechanism is the one wave 1 built. `SessionAudio.setMicMuted` — which mutes the voice processor's input on the combined engine and pauses the capture engine on the split one — stays the single muting path; what changes is that it now takes an EFFECTIVE mute the session composes from its two reasons: the user's mute and the background keep-alive's privacy mute. Neither can clear the other, so a user who muted before pocketing their phone comes back still muted, and backgrounding no longer un-mutes anyone on return. It also latches the state, so a mute made while the microphone permission prompt is still open lands on the engine that grant creates instead of being lost. The control is offered only where there is something to mute: the session's resolved `micEnabled` (a profile can turn the mic on or off), a platform with an app-accessible input (never tvOS), and a TCC grant the OS hasn't refused. Absent rather than greyed on the HUD and the touch discs, greyed on the menu, and the macOS start-of-stream banner only teaches ⌃⌥⇧A when the session actually sends a microphone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f3e122b0d0 |
feat(apple): the mic hears you, not the game — voice processing joins the engines
The Apple client on a loudspeaker was the primary reported echo source: two AVAudioEngines by design (arbitrary mic/speaker pairs, two clocks) meant no unit ever saw render and capture together, which is exactly what setVoiceProcessingEnabled needs. With the mic enabled and the new "Echo cancellation" toggle on (both defaults), playback and capture now share ONE engine with the system voice processor engaged — AEC, noise suppression and AGC — and the input node's other-audio ducking pinned to .min with advanced ducking off, because this is a game stream, not a call: the host's audio must never dip under the outgoing voice. The two-engine path survives where it is the right answer: echo cancel off, macOS sessions with a hand-picked speaker/mic UID or input channel (the voice processor only follows the system default devices, and its capture side is its own mono mix — a per-channel pick can't survive it), and any machine whose voice processor refuses to engage. Every failure falls back to a working configuration rather than silence. The capture chain reads the input format after the processor is enabled, so its mono/lower-rate output flows through the existing converter unchanged; a first-run permission grant swaps the playback-only engine for the combined one in place, reusing the same jitter ring and drain thread; background mic muting mutes the processor's input instead of pausing the shared engine (playback keeps running). echo_cancel rides the full settings plumbing micEnabled has — defaults key, effective settings, profile overlay (serialized as `echo_cancel`; the Rust catalog carries it via unknown-key passthrough until it grows the field), a captioned toggle beside the Microphone row, and a gamepad settings row. tvOS behavior is untouched (playback only, as before). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e54258b8ac |
feat(apple): the mic uplink drops to mono 10 ms packets and stops shoveling its FIFO
The capture path carried three latencies that had nothing to do with the network: a 2048-frame tap request (42.7 ms bursts before the first sample could even be sliced), 20 ms Opus framing, and a mono signal duplicated into both stereo channels because the encoder was configured like the downlink. CoreAudio's Opus encoder takes mFramesPerPacket=480 and mChannelsPerFrame=1 just fine — probed empirically: the converter truly emits 10 ms mono CELT packets (TOC config 30), one per 480-frame chunk, and the stereo-shaped decoder upmixes them with the tone intact — so the uplink now asks for 10 ms tap buffers and encodes 48 kbps mono 10 ms packets, with 960 kept only as an init-time fallback. The host decodes any Opus frame ≤120 ms, so nothing changes on the wire's far end. The tap thread also stops burning cycles per callback: the mono fold and resampler scratch buffers are allocated once (regrown only if a larger device quantum ever arrives), and the chunk slicer walks a head index instead of removeFirst — which memmoved the entire backlog for every packet on a render-adjacent thread. iOS additionally asks the session for 5 ms IO quanta at 48 kHz when the mic is on (best-effort; the hardware decides). Verified: swift build (macOS arm64), swift test OpusCodecTests + AudioChannelFoldTests, and an iOS arm64 cross-build; the 480/mono behavior confirmed by TOC inspection on macOS 15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
957cf7781c |
feat(apple): a Pencil near the glass pins the panel at the link ceiling
ci / web (push) Successful in 2m4s
ci / docs-site (push) Successful in 2m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 3m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m11s
ci / rust (push) Failing after 2m7s
apple / swift (push) Successful in 4m59s
deb / build-publish (push) Successful in 6m51s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-host (push) Successful in 7m10s
docker / builders-arm64cross (push) Successful in 8s
android / android (push) Successful in 8m44s
arch / build-publish (push) Successful in 8m35s
docker / deploy-docs (push) Successful in 33s
flatpak / build-publish (push) Successful in 8m0s
windows-host / package (push) Successful in 18m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m5s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m1s
release / apple (push) Successful in 25m56s
apple / screenshots (push) Canceled after 4s
UIKit delivers Pencil events at the PANEL's cadence, and the deadline link's rate hint pins the panel to the stream rate (min = preferred = stream Hz, deliberately — the anti-idle-stall floor). Net effect: a 60 fps stream on a 120 Hz iPad silently halved pencil sampling, 8.3 → 16.7 ms per event batch — exactly the workload (drawing) that feels it most. FrameRateHint gains a boost that lifts min/preferred to the range ceiling while a Pencil is in range (hover or contact): PencilStream surfaces proximity transitions from its emit() choke point, the view controller debounces release by 2 s (edge-of-canvas hover flicker must not thrash the link), and Stage2Pipeline/SessionPresenter stage it like the rate hint — applied from the link's own thread, no-op under arrival/glass pacing. Presents still pace at stream rate (extra updates vend into the newest-wins stash), so the cost is empty link wakes scoped to proximity. pf.present logs engage/release for on-glass verification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7cf71dd218 |
feat(clients): the Apple deadline presenter reports its latch phase
Phase-locked capture had no client half on Apple — the host's v3 controller (grid-locked submits, coherence-gated engage) shipped with only the Android reporter feeding it. Now the stage-4 link thread flushes the same v2 circular arrival-phase statistic at ~1 Hz: - PhaseReporter (Stage2Pipeline): the decode callback deposits per-AU reassembly-completion stamps, the CAMetalDisplayLink update deposits the latch grid (period = window-min of update spacing), and the flush ports punktfunk_core::phase::circular_latch verbatim — a period-smeared Wi-Fi link reads coherence ≈ 0 and the host correctly never engages; a wired link opens the gate. Binds/unbinds per session like DecodeReport. - PunktfunkConnection.reportPhase wraps the existing ABI entry point. - Hello honesty: iOS/tvOS advertise CLIENT_CAP_PHASE_LOCK (macOS stays without — the stage-2 arrival presenter has no latch grid), Android now sets the bit its reporter already earned, and the ABI grows the PUNKTFUNK_CLIENT_CAP_PHASE_LOCK mirror const (header regenerated). Advisory in v1: the host arms on report receipt. - Stage-3 doc comment no longer calls itself the tvOS default (stage-4 took that over in the 2026-07 rebuild). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c002ca8746 |
fix(clients): pen batches split at the wire cap and the heartbeat survives tracking mode
Three drawing-path defects on the pen clients: - The Apple in-range heartbeat was re-armed on every emit (~120 run-loop mutations/s during a stroke) and lived in .default run-loop mode only — a tracking-mode excursion >200 ms with a stationary pen crossed the host's force-release failsafe and dropped the held stroke. Now one long-lived 50 ms timer in .common mode, resending only after ≥50 ms of send silence. - Both clients TRUNCATED an over-8-sample coalesced run instead of splitting it into consecutive batches (the send_pen contract): Apple suffix(8) dropped the oldest samples, the Android JNI clamped count. An over-cap run means the UI thread hitched — exactly when dropping stroke geometry hurts most. Apple splits in emit(); the Android JNI loops send_pen over ≤8-sample chunks (Kotlin's per-emit ceiling is now 64 = a >250 ms stall). - The iOS letterbox mapper did a lock+FFI currentMode() read per coalesced sample on the main thread, contending the ABI lock the batch send takes next — now a 250 ms TTL cache (mid-stream requestMode still tracked). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
de8430097e |
chore(clients): the bundled licence notices catch up with the root file
ci / web (push) Successful in 1m22s
ci / rust-arm64 (push) Successful in 1m31s
decky / build-publish (push) Failing after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Failing after 2m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
apple / swift (push) Successful in 3m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m28s
deb / build-publish-client-arm64 (push) Successful in 4m4s
docker / builders-arm64cross (push) Successful in 8s
deb / build-publish-host (push) Successful in 5m29s
deb / build-publish (push) Successful in 4m2s
android / android (push) Successful in 7m13s
docker / deploy-docs (push) Successful in 39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m31s
arch / build-publish (push) Successful in 9m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m54s
flatpak / build-publish (push) Successful in 6m29s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m52s
apple / screenshots (push) Successful in 20m57s
windows-host / package (push) Successful in 14m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
release / apple (push) Successful in 25m29s
The Apple and Android apps carry their own copy of THIRD-PARTY-NOTICES.txt and show it on the acknowledgements screen. `7fc2775f` refreshed the root file but not the copies — running the python generator directly skips the sync `scripts/gen-third-party-notices.sh` does — so they had drifted to a 531-crate snapshot that predates the whole vendored-source section. That section is where the OS marks' attribution lives, so both apps have been shipping Font Awesome's CC BY 4.0 icons with no attribution visible at all. Re-synced, which also carries the Bazzite Apache-2.0 notice the previous commit adds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5520167958 |
feat(clients): the OS marks tell gaming distros apart, and Windows looks current
Three things were wrong with the host-card OS icons. The Windows mark was Font Awesome 5's, which is still the Windows 8/10 flag with the perspective skew — dated next to the flat four-pane mark Microsoft has shipped since Windows 11. No icon set has the current one (Simple Icons carries no windows/microsoft slug at all), so it is drawn here: four equal squares at the authentic 11.377 + 1.246 proportion. The Decky plugin was pulling FaWindows straight from react-icons, so it now inlines the masters like the web console does, or it would have kept the old flag regardless. Bazzite, CachyOS and Nobara collapsed onto their family's mark. The host already advertises the full chain, so this is purely missing art: all three now ship a leaf mark, because "a Bazzite box" and "a Fedora box" are different machines to the person reading the card. CachyOS and Nobara come from Simple Icons; Bazzite has no icon anywhere, so its "b" is lifted out of the project's own badge (Apache-2.0, attributed). On Android every non-square mark was stretched. A VectorPainter maps the viewport onto the ImageVector's default size with independent x and y scales, so declaring a 448x512 Tux as 24x24 dp squashed it — silently, no crash, no warning. The longest viewport edge now sets the 24 dp box and the other follows the ratio, which is what Icon()'s ContentScale.Fit expects. A unit test pins the invariant; every other client was already correct. Also: scripts/gen-os-icons.sh replaces the undocumented hand-run pipeline that turns a master into the GTK symbolic SVG, the Windows PNG and the Apple template PDF. It reproduces the committed artifacts byte-identically. Verified: web and Decky typecheck, Decky bundles, Android compiles and its tests pass, PunktfunkKit builds, osinfo's tests pass. Not verified on glass, and the GTK/Windows client crates do not build on macOS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
31db452ca9 |
feat(client/apple): the host tile wears its OS mark where the initial was
apple / swift (push) Successful in 4m32s
ci / web (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
ci / docs-site (push) Successful in 1m18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 40s
ci / rust (push) Failing after 6m10s
ci / rust-arm64 (push) Successful in 14m47s
release / apple (push) Successful in 25m17s
apple / screenshots (push) Canceled after 13m52s
Apple parity with the Android card (a94b1d3c's sibling): the OS mark moves out of the status row and into the tile, replacing the monogram. The initial says nothing the name beside it doesn't already say — twice over on a row of home-worker-N boxes — while the mark identifies the machine at a glance. Both host surfaces, because on tvOS the console home is the only one there is: the touch cards (saved and discovered) and GamepadHomeView's badge, which needed the chain carried on HomeTile to reach it. Sized to the monogram's own point size, so it lands at ~48% of the tile everywhere, and tinted through the same foregroundStyle the letter used — the assets are template imagesets, so they follow it like an SF Symbol. A host that advertises no OS chain, or one we ship no art for, keeps its letter: `osIconImage` already returns nil for both, so a mixed row still reads as one set. The mark carries the accessibility label now, since the status row it used to ride no longer names the OS. Untouched: the widget draws no monogram, and AboutView's is the app's own logo, not a host's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
88c8688b47 |
feat(client/apple): host cards wear the OS mark, and the store learns it
`DiscoveredHost` reads the new `os=` TXT (sanitized in PunktfunkShared's OsChain.swift — the pure grammar + walk, mirrored from pf-client-core so every platform resolves identically, and dependency-free so the widget could use it); `StoredHost` appends optional `osChain` per the frozen app↔widget contract (optional + appended last; legacy JSON still decodes, pinned by the round-trip tests), and `HostStore.updateOsChain` learns it beside the MACs. The art rides PunktfunkKit's proven resource path (the fonts precedent): ten template vector imagesets in Resources/OsIcons.xcassets, compiled by the Xcode build into the Kit bundle's Assets.car (verified: all ten resolve in the built app) and tinting via foregroundStyle like an SF Symbol. Saved and discovered cards lead their status line with the mark; a host that advertises nothing renders exactly as before. GamepadHome tile + widget marks are follow-ups. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |