2a67c02f7e3da05776dd4e7a4388f0745d71d39e
247
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2a67c02f7e |
fix(clients/cursor): the host must not composite a pointer under a released client's own cursor
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 1m9s
android / android (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m36s
ci / web (pull_request) Successful in 4m31s
ci / docs-site (pull_request) Successful in 4m43s
ci / rust-arm64 (pull_request) Successful in 6m50s
ci / rust (pull_request) Failing after 12m8s
Streaming a KDE desktop showed two cursors: the one the user was moving, and a
second one sitting underneath it that never moved. It was not KDE's — KWin 6.7.3
in cursor-as-metadata mode calls `setRenderCursor(false)` on every recorded buffer
and hands the cursor item to an exclusive `ItemTreeView`, so `shouldRenderItem()`
skips it and no pointer is ever painted into that stream. It was ours.
Both clients declared the render model as `captured && desktop`, so ANY released
pointer handed compositing back to the host. But releasing does not remove the
local cursor — it restores the ordinary window arrow over the video. The host then
blends its own pointer in underneath, and since a released client forwards no
motion, nothing drives it: it stays frozen wherever the host pointer was last left.
Caught live on the host with the render-model diag:
cursor diag: client_draws=false blended=true live=Some((-1, 622, true))
x = -1 — parked on the streamed output's left edge, unchanged sample after sample,
while the user moved their own cursor around freely. Engaging capture flipped it to
`client_draws=true blended=false` and the duplicate vanished, which is why it only
looked "stuck when not dragging": dragging means engaged, and engaged was the one
state that behaved.
The host may composite ONLY while the client holds a grabbed, hidden pointer — the
capture model, engaged — which is the single state with no local cursor on screen.
Released now counts as "the client draws it": the host stops compositing and keeps
forwarding shape/state over the channel (the forwarder ticks on this side of the
flip), so re-engaging is seamless and the client's cached shape stays warm.
|
||
|
|
00d4026054 |
Merge pull request 'Worktree field kleisty triage' (#69) from worktree-field-kleisty-triage into main
arch / build-publish (push) Failing after 40s
apple / swift (push) Successful in 1m26s
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 2m30s
deb / build-publish (push) Successful in 3m43s
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 7s
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 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Successful in 2m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 6m51s
docker / builders-arm64cross (push) Failing after 25s
docker / deploy-docs (push) Failing after 1m57s
release / apple (push) Successful in 9m17s
deb / build-publish-host (push) Successful in 7m58s
android / android (push) Successful in 12m19s
ci / rust (push) Successful in 12m1s
flatpak / build-publish (push) Successful in 9m40s
apple / screenshots (push) Successful in 5m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
windows-host / package (push) Canceled after 2m58s
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-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 1s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #69 |
||
|
|
81b4f76c4d |
fix(client): a session ending on purpose stops reading as a failure
The desktop clients turned every host-side close into "Host ended the session", and a reason string means "abnormal" to everything downstream: the GTK and Windows shells raised a banner, the console overlay drew a status strip. Quitting a game you launched yourself produced all of that. Now only a host error or a lost connection carries a message; the deliberate endings return the silence those shells already give a clean exit, which is also what puts the console back in its library with nothing in the way. The Apple client gains the same distinction. It had one line for every ending — "Session ended by <host>." — which is fine for an operator stopping the session and wrong for a link that died, so each now says what happened. A game exiting stays silent and returns to the library it was launched from. Both read the reason while the connection is still up, because tearing it down is what makes it unreadable, and both fall back to their previous wording when there is no verdict — an older core, or a close that raced the read — rather than inventing a new one for a case they cannot see. |
||
|
|
d74639de70 |
Merge pull request 'A safe-area resolution that keeps the picture out of the notch' (#68) from worktree-launchers-safearea-exclusions into main
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 1m53s
android / android (push) Successful in 5m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
ci / web (push) Successful in 1m4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 1m12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 34s
ci / rust (push) Failing after 9m39s
release / apple (push) Successful in 9m11s
apple / screenshots (push) Successful in 5m42s
Reviewed-on: #68 |
||
|
|
d4dd5f7a3d |
feat(client): a game exiting takes you back to its library
Quit a game you launched from a host's library and the stream ended with "Session ended by <host>." on the host-selection screen — an error report for something you had just done on purpose, and several taps away from starting the next title. The host has always said what happened: it closes the connection with APP_EXITED when the game it launched for a session exits, and that code's own documentation describes this feature. Nothing ever read it — a search across every client found zero consumers. (It also could not reach anyone until the previous commit, since the close only happens once the lease declares the game gone.) The core now records the reason as it observes the close, latched before the shutdown flag because different threads watch the two, and exposes it as punktfunk_connection_game_exited. Purely additive: a client that never asks behaves exactly as before, the host sends identical bytes, and the wire version is untouched — ABI 17. The Apple client asks while the connection is still up, then treats a game exit as the normal finish it is: no error banner, and if the session began as a library launch it reopens that library so the next title is one tap away. Any other ending — a stop, the host going away, network loss — is unchanged. The other clients keep their existing end-of-session behaviour; the call is there when they want it. |
||
|
|
ea762b849d |
fix(client/ios): Escape stays in the game instead of freeing the pointer
Pressing Escape mid-stream on an iPad handed the mouse back to iPadOS: the captured cursor was swapped for the system one and the game stopped receiving relative motion, so aiming died until you clicked back in. Two previous attempts treated that release as unavoidable and built recovery around it — a re-lock burst, then a click that re-asks. Both came back from the field unchanged, because both fought the release after it had already happened, inside the cooldown the platform applies straight after its own "let me out" gesture. The release was never unavoidable. This app had no UIKit key handling at all: every key arrives on the GameController path, which is a parallel HID feed that does not consume the UIKit event, and the only thing that ever became first responder was the video view, and only to summon the soft keyboard. So every hardware Escape reached UIKit unclaimed — and an unclaimed key press is precisely what lets the system apply its own default for that key. Apps that read a hardware keyboard the ordinary way consume the event as a side effect and never see this. So claim it. The stream controller becomes first responder while capture is engaged and takes Escape in pressesBegan/pressesEnded, passing every other press to super untouched. Escape still reaches the host on the GameController path, so in-game menus open exactly as before; only the system's own interpretation is suppressed. Scoped to captured input, so Escape keeps dismissing sheets and leaving full screen whenever the stream doesn't own the keyboard, and the deliberate ways out are untouched — Cmd-Escape and Ctrl-Opt-Shift-Q are read off the same GameController path and clear capture themselves. The recovery path stays as a backstop and is retimed to match what was measured: the old burst spent its entire budget within ~0.6 s of the drop, i.e. wholly inside the cooldown, where the answer can only be no. Retries now continue at 1.2 s and 2.4 s, and quietly — they don't hide the cursor or mute pointer motion the way the burst does, so a longer recovery costs nothing when it fails. |
||
|
|
fbdad8d917 |
Merge pull request 'fix(clients): host discovery heals itself, and every client can rescan' (#67) from worktree-host-discovery-refresh into main
ci / web (push) Successful in 1m14s
apple / swift (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m20s
deb / build-publish (push) Successful in 3m53s
deb / build-publish-host (push) Successful in 4m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
ci / rust-arm64 (push) Successful in 6m58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 17s
docker / builders-arm64cross (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 2m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
android / android (push) Canceled after 8m10s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Successful in 8m22s
ci / rust (push) Canceled after 8m34s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m13s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 7m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m13s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m37s
flatpak / build-publish (push) Failing after 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 15m37s
Reviewed-on: #67 |
||
|
|
78ad675507 |
feat(clients): a safe-area resolution that keeps the picture out of the notch
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 2m2s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m19s
Picking the device's native mode on a phone hands the host the panel's own aspect ratio, so the aspect-fit presenter fills every pixel — including the ones behind the sensor housing and under the four rounded corners. That is why the corners look cut off at max resolution while 1080p has always been fine: a 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe regions. So the fix is entirely a sizing one — no layout change, no input change. Ask the host for a mode narrowed by the unsafe inset and the existing aspect-fit centres it inside the safe region; pointer mapping follows for free, because both clients derive the picture rect from the live host mode rather than assuming full-bleed. Apple: `SafeDisplay` (PunktfunkShared, pure + unit-tested) and a "This device (safe area)" row beside the native one, using Moonlight's formula — full native height, width less the left+right safe insets. The stream is always landscape but the settings screen may be portrait, where the same housing is reported on `top` and the horizontal insets read zero; the portrait top inset stands in, gated so an iPad's status bar never fabricates an inset. Android: the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at connect like the existing `0`=native one. The cutout insets get the same portrait fallback, and the rounded corners are added on top — Android does not count them as cutout, and a full-height picture needs exactly the corner radius of horizontal clearance. Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions and an inset subtraction lands odd about half the time. Where a display has neither cutout nor rounded corners the safe mode equals the native one, which on Apple lets the existing dedup drop the duplicate row. |
||
|
|
b25e6eda91 |
fix(clients): host discovery heals itself, and every client can rescan
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 4m16s
android / android (pull_request) Successful in 6m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 7m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 3m30s
ci / rust (pull_request) Successful in 15m13s
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.
Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:
- A failed resolve was never retried. `browseResultsChangedHandler`
only fires when the result SET changes, and a host whose resolve
failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
throwaway UDP flow used to resolve an address could sit in
`.preparing`/`.waiting` forever, and a service with a connection in
flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
re-armed). On iOS that is where the local-network privacy prompt
lands on first launch after install: the browse starts, the system
asks, and the browser waits. Granting does not revive that browser —
only a new one sees the grant. That is the reported first-run bug.
HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).
Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.
The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.
Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that
|
||
|
|
5a7f7f0fc5 |
feat(clients/gamepad-ui): section tabs, background palettes, and a backdrop that moves everywhere
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m36s
android / android (pull_request) Successful in 3m33s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 8m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m47s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
The console settings were one 30-row scroll, which on a Deck meant thumbing past Video and Audio to reach the pad settings. They are now split across sections — Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the desktop console, which alone carries the touch/mouse rows. L1/R1 walks them, each section remembers where its cursor was, and the names are the same word on every client so a setting is where you looked for it last. Shoulders are not the only route, because a D-pad remote hasn't got any: on Android, Up from the first row moves onto the strip (left/right walks sections there, A drops back in), and on tvOS the pills are focusable, so the focus engine handles it — a Siri Remote has no extended gamepad profile and never reaches the input poll at all. The desktop console needs neither; PageUp and PageDown already map to the same events. New "Background" row, six palettes: Violet (the brand default), Tide, Forest, Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over the ONE colour field each client already draws, so every palette inherits its structure and Violet is the identity transform — existing installs see exactly what they see today. The maths is ported three times (Rust/Swift/Kotlin) under one shared `ui_palette` key, with the same assertions pinned in each language. It is presentation only, so it is a device preference and never part of a profile. The form screens no longer have a backdrop of their own. Settings, add-host and pair used to sit on a still gradient; they now wear the same living field at a calm mix — pools dimmed onto the palette's own corner colour, vignette halved so rows that run to the edges don't get crushed. On the desktop console that collapsed the old aurora-over-static crossfade into one shader pass with a chased uniform. Motion speed is identical in both modes on purpose: changing it would make the field jump mid-transition. Nothing in the gamepad UI is backed by a static image now, and Reduce Motion (Apple) / "remove animations" (Android) still freeze it. Also: the settings screen had no raster coverage at all — the eyeball dump is `#[ignore]`d — so a new test draws every tab, and the Android screenshot set gains a console-settings scene. Both earned their keep immediately: the renders showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now, and the Section cell only appears where shoulders exist) and the form backdrop crushing its own edges. |
||
|
|
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> |