9 Commits

Author SHA1 Message Date
enricobuehler 31c382fde0 chore(release): 0.5.1
linux-client-screenshots / screenshots (push) Waiting to run
android-screenshots / screenshots (push) Waiting to run
web-screenshots / screenshots (push) Waiting to run
audit / cargo-audit (push) Successful in 54s
apple / swift (push) Successful in 1m15s
ci / web (push) Successful in 57s
android / android (push) Successful in 5m8s
ci / docs-site (push) Successful in 1m1s
ci / bench (push) Successful in 4m40s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 40s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / rust (push) Waiting to run
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / deploy-docs (push) Waiting to run
windows / build (x86_64-pc-windows-msvc) (push) Successful in 46s
deb / build-publish (push) Successful in 9m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has started running
flatpak / build-publish (push) Successful in 4m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has started running
release / apple (push) Successful in 7m51s
apple / screenshots (push) Has started running
windows-host / package (push) Successful in 6m46s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m7s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has started running
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:05:43 +00:00
enricobuehler d707ee4d4e feat(apple,android): three-way touch input — trackpad cursor (default), direct pointer, real multi-touch passthrough
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
The two touch clients had exactly complementary gaps: iOS forwarded fingers
ONLY as raw wire touches (no way to drive the host cursor from the touch
screen), Android had the two mouse modes but no passthrough. Both now share
one three-way "Touch input" setting: Trackpad (default) / Direct pointer /
Touch passthrough.

iOS/iPadOS: Input/TouchMouse.swift ports the Android gesture engine 1:1
(same px-based acceleration curve; tap=click, two-finger tap=right-click,
two-finger drag=scroll, tap-then-drag=held drag, three-finger tap=stats
HUD via the shared hudEnabled default); direct-pointer mode maps through
the aspect-fit letterbox; the previous always-on behavior lives on as the
passthrough option. The mode latches per gesture (a Settings change never
splits one gesture across models), touchesCancelled releases held state
without synthesizing a click, and session stop flushes a mid-drag button.
Settings picker on iPhone + iPad next to the iPad-only pointer-capture
toggle. Deliberate default change: trackpad, not passthrough.

Android: new nativeSendTouch JNI shim → wire TouchDown/Move/Up (the host
already injects real touch on every backend — libei touchscreen, wlroots,
KWin fake-input, SendInput); streamTouchPassthrough forwards every finger
with stable ids and lifts still-held contacts on teardown; the trackpadMode
Boolean becomes the TouchMode enum (old pref migrated on load, never
rewritten) with a Settings dropdown.

Verified: macOS swift build + full suite (incl. new TouchMouseTests), iOS
Simulator Swift compile, cargo check/fmt/clippy on the native crate, Kotlin
app+kit compile + unit tests. On-glass feel of the iOS ballistics and
Android passthrough against a touch-aware app still pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:02:12 +02:00
enricobuehler e8196b33b8 feat(client/linux): Steam Deck batch — idle gamepad grab, fullscreen streams, in-band HDR colors, gamescope-safe settings, pad-pin persistence
windows-host / package (push) Successful in 6m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 54s
apple / swift (push) Successful in 1m17s
audit / cargo-audit (push) Successful in 17s
android / android (push) Successful in 3m46s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 57s
release / apple (push) Successful in 8m41s
deb / build-publish (push) Has been cancelled
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Successful in 8m21s
Root-caused fixes from on-Deck testing (owner + first external tester):

- System input broke while the app was merely OPEN: SDL's Steam Deck HIDAPI
  driver clears the built-in controller's "lizard mode" (trackpad-mouse,
  clicky pads) at device ENUMERATION and keeps feeding the firmware watchdog
  (SDL_hidapi_steamdeck.c InitDevice/UpdateDevice) — and we enabled that
  driver at startup and held every pad open app-lifetime. The Valve HIDAPI
  hints are now enabled only while a session is attached, and only the active
  pad is opened (Settings enumerates via SDL's ID-based metadata getters, no
  open). Close/detach hands the hardware back; the watchdog restores lizard
  mode within seconds. This also unblocks click-to-capture on the Deck (the
  dead trackpad made "input not passed through" a symptom, not a cause).
- Washed-out colors from a Windows host with an HDR desktop: the host ships
  Main10 BT.2020 PQ IN-BAND (correct VUI) while the Welcome still says SDR;
  this client rendered everything as BT.709 narrow. Colour signaling is now
  read per-frame (video::ColorDesc from the AVFrame CICP fields) and drives
  the GdkDmabufTexture color state, the software path's swscale matrix/range
  plus a tagged MemoryTexture for PQ, and an "· HDR" HUD chip — GTK tone-maps
  correctly on SDR displays, mid-session SDR↔HDR flips included. Regression-
  tested against a checked-in Main10 PQ fixture (tests/pq-frame.h265).
- Streams start fullscreen by default (Settings toggle; F11 / the controller
  chord lead out, and the pointer at the top edge reveals the header while
  input isn't captured — a Deck desktop has no F11). Gaming-Mode launches
  (--fullscreen / Deck env) build the stream page with NO header bar at all:
  gamescope doesn't reliably ACK xdg_toplevel fullscreen, so anything keyed
  on is_fullscreen() could leave the title bar drawn over the stream.
- Game Mode settings were uneditable: GTK popovers are xdg_popups, which
  gamescope never maps for nested apps — every ComboRow dropdown flashed and
  died. Under gamescope the preferences dialog now uses in-window selection
  subpages (PreferencesDialog::push_subpage) via a ChoiceRow that stays a
  stock ComboRow on desktops. Covered by an in-process GTK test
  (choice_row_modes, #[ignore]d — needs a display).
- Forwarded-controller pin persists across restarts (Settings::forward_pad,
  stable vid:pid:name key — SDL instance ids are per-run) and survives
  disconnects; automatic selection skips Steam Input's sensor-less virtual
  pad (28de:11ff) so gyro doesn't silently die on Bazzite/Deck.
- "Punktfunk" branding in the About dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler fd699b3e2c feat(decky): plugin overhaul — on-Deck update check, exec-bit-free runner, About/host-detail UI, Punktfunk branding
Fixes from live debugging on the Deck:

- check_update() was dead on-device: Decky Loader's embedded (PyInstaller)
  Python has no usable default CA paths, so every HTTPS fetch failed with
  CERTIFICATE_VERIFY_FAILED. Build the SSL context explicitly: default paths
  first, then the known system bundles (SteamOS/Arch, Debian, Fedora/Bazzite,
  openSUSE), then certifi if importable. Verification stays on; the check
  stays offline-tolerant with its 30-min cache.
- "could not chmod runner" on every use: Decky extracts plugin zips without
  exec bits into a root-owned dir the unprivileged backend can't chmod. The
  Steam shortcut now launches the runner through /bin/sh with the script as a
  %command% argument — no exec bit needed, existing shortcuts migrate on
  reuse, the chmod attempt is gone.

UI/structure:

- index.tsx (660 lines) split into page/pair/settings/hooks/boundary modules;
  PluginErrorBoundary kept guarding every surface.
- New About section/tab: visible version + channel, explicit check-for-updates
  (forces past the cache, always toasts an outcome), setup-guide link, leave-
  chord help, and a Force-stop backstop for a wedged stream.
- Host rows open a details modal (address, protocol, pairing policy, paired
  state, fingerprint). Settings gain 1280×800 (Deck native), Xbox One and
  DualShock 4 pad types, and a host-compositor picker.
- Update flows note the Decky store contact can stall a couple of minutes on
  networks that blackhole plugins.deckbrew.xyz (observed live).
- "Punktfunk" in all user-facing strings; plugin id/paths/env unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler 79dd8f58e3 docs(readme): status refresh — Windows client streaming live, console features
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler be879c946a fix(host/logs): mdns-sd noise gate + tracing-log target normalization in the log ring
log-crate events arrive through the tracing-log bridge under the shim target
"log" — normalize them back to the real module path (NormalizeEvent) so the
console's target column and the noise gate see mdns_sd::… , and suppress the
bridge's log.* bookkeeping fields like the stderr fmt layer does.

Gate known-chatty third-party DEBUG targets (mdns-sd DEBUG-logs every
unparseable multicast packet — one AirPlay device floods thousands of entries
per hour) to INFO-and-up in the ring, so ambient LAN noise can't evict the
tail the ring exists to preserve. stderr under RUST_LOG is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler f3646d4e7c feat(apple/gamepad): claim controller system gestures during capture — PS button opens the Steam overlay, share/create stops screenshotting locally
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 2m1s
ci / web (push) Successful in 56s
android / android (push) Successful in 3m19s
ci / docs-site (push) Successful in 58s
deb / build-publish (push) Successful in 3m13s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
release / apple (push) Successful in 8m1s
apple / screenshots (push) Successful in 5m33s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
While a pad drives a stream, GamepadCapture now sets EVERY element's
preferredSystemGestureState to .disabled (restored to .enabled on unbind).
iOS/macOS attach system gestures to several controller buttons — share/create
took a LOCAL screenshot instead of reaching the game, and only the Home
element was opted out before. With the gestures claimed, the already-wired
chains do their job: PS/Home → wire guide → BTN_MODE on the virtual xpad
(the Steam-overlay button) / the PS bit on the virtual DualSense.

Also fold the share/create/capture element (GCInputButtonShare) into the
back/select wire bit — clone pads like the GameSir G8 expose their screenshot
button only as the share element, not buttonOptions (OR onto the same bit, so
double-exposed pads are harmless). The G8's other extra button (M) is a
firmware-local modifier (turbo/hair-trigger/swap) invisible to the OS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:36:16 +02:00
enricobuehler 396c3453f5 feat(apple/gamepad): rewrite rumble renderer — bounded divergence + iOS 27 plain-player fix
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m59s
ci / web (push) Successful in 51s
android / android (push) Successful in 3m44s
ci / docs-site (push) Successful in 1m3s
deb / build-publish (push) Successful in 3m11s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m47s
release / apple (push) Successful in 8m38s
apple / screenshots (push) Successful in 5m27s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m26s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
Ground-up RumbleRenderer rewrite around one principle: rumble is idempotent
state on a lossy channel, and the actuator's divergence from it must be
bounded, not best-effort. The old renderer rebuilt an infinite-duration
CHHapticAdvancedPatternPlayer per 0xCA datagram via an async stop; one stop
lost inside CoreHaptics left an unstoppable player buzzing forever (the
"entered the menu and rumble never stopped" bug).

- Finite 4 s segments, never infinite events — a leaked player self-silences;
  steady levels re-arm seamlessly ON the engine timeline (no stop/start race)
- GamepadFeedback drains the rumble plane DRY per cycle, newest-wins (was one
  datagram per 8 ms through a 16-deep drop-newest queue = lag + shed stops)
- Host 500 ms state refreshes dedupe to a liveness stamp; zero applies
  immediately; nonzero ramps throttle to one rebake/25 ms per motor
- Throwing player stop escalates to engine.stop() (kills leaked players);
  1.6 s staleness watchdog (Policy.session) force-silences on a dead channel;
  the test panel holds levels via Policy.manual
- Plain makePlayer, NEVER makeAdvancedPlayer: gamecontrollerd's controller
  haptics server advertises `adv players: 0`, and iOS 27 beta 2 hard-drops
  advanced loads with an XPC decode fault (-4811/4097, rumble silently dead).
  Live-verified on an iOS 27 beta 2 iPhone: DualSense rumble works
- Split-handle engines fall back to one combined .default engine on repeated
  failure; renderer publishes health transitions and the test panel shows
  them (a refused system service no longer reads as silent app breakage)
- Per-motor sharpness on split handles (0.3 heavy / 0.7 light); macOS
  DualSense raw-HID path gains a ~1 s keepalive re-write while nonzero
- RumbleTuningTests pin the scheduling math, tuning relations, and a
  queue/ticker teardown smoke test

Stuck-rumble streaming repro revalidation on glass still pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:06:45 +02:00
enricobuehler 6921e147dd ci(release): idempotent registry publish — survive re-tagged releases
apple / swift (push) Successful in 1m3s
ci / rust (push) Successful in 2m2s
ci / web (push) Successful in 56s
android / android (push) Successful in 3m22s
ci / docs-site (push) Successful in 58s
apple / screenshots (push) Successful in 5m38s
deb / build-publish (push) Successful in 3m12s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
flatpak / build-publish (push) Successful in 4m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m12s
docker / deploy-docs (push) Successful in 19s
A moved release tag re-fires the publish workflows, and the Gitea
registries reject duplicate uploads with 409 (deb pool, rpm group, and
the generic packages' versioned URLs; the channel aliases already
pre-deleted). Delete any prior copy of the exact version before
uploading (404 on first publish tolerated), so a republished tag
overwrites instead of wedging — v0.5.0's retag left stale no-port-change
artifacts published and every re-run red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:23:04 +00:00
51 changed files with 3101 additions and 1038 deletions
+8
View File
@@ -126,6 +126,14 @@ jobs:
run: | run: |
for DEB in dist/*.deb; do for DEB in dist/*.deb; do
echo "uploading $DEB" echo "uploading $DEB"
# A re-tagged release re-fires this workflow and the apt registry 409s on duplicate
# package versions — delete any prior copy of this exact name/version/arch first
# (404 on the first publish is fine).
NAME=$(dpkg-deb -f "$DEB" Package)
VER=$(dpkg-deb -f "$DEB" Version)
ARCH=$(dpkg-deb -f "$DEB" Architecture)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login. # PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload" "https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
+7 -2
View File
@@ -122,8 +122,13 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE" BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
# 1) Immutable, versioned URL + its update manifest (the manifest's `artifact` points # 1) Versioned URL + its update manifest (the manifest's `artifact` points here, so the
# here, so the published sha256 keeps matching what Decky later downloads). # published sha256 keeps matching what Decky later downloads). A re-tagged release
# re-fires this workflow and the registry 409s on duplicate uploads — delete any
# prior copy of this version first (404 on the first publish is fine).
for f in punktfunk.zip manifest.json; do
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$VERSION/$f" || true
done
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
"$BASE/$VERSION/punktfunk.zip" "$BASE/$VERSION/punktfunk.zip"
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
+4 -1
View File
@@ -133,7 +133,10 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE" BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
# 1) Immutable, versioned URL. # 1) Versioned URL. A re-tagged release re-fires this workflow and the registry 409s on
# duplicate uploads — delete any prior copy first (404 on the first publish is fine).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"$BASE/$VERSION/$BUNDLE" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
"$BASE/$VERSION/$BUNDLE" "$BASE/$VERSION/$BUNDLE"
echo "published $BASE/$VERSION/$BUNDLE" echo "published $BASE/$VERSION/$BUNDLE"
+8
View File
@@ -103,6 +103,14 @@ jobs:
for rpm in dist/*.rpm; do for rpm in dist/*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
echo "uploading $rpm" echo "uploading $rpm"
# A re-tagged release re-fires this workflow and the rpm registry 409s on duplicate
# package versions — delete any prior copy of this exact name/version-release/arch
# first (404 on the first publish is fine).
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload" "https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
done done
+29 -4
View File
@@ -168,11 +168,26 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
controller discovery + selection in Settings (`GamepadManager` — exactly one pad controller discovery + selection in Settings (`GamepadManager` — exactly one pad
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
controller, user-overridable), capture incl. DualSense touchpad/motion controller, user-overridable), capture incl. DualSense touchpad/motion
(`GamepadCapture`/`GamepadWire`), feedback rendering (rumble → CoreHaptics; lightbar / (`GamepadCapture`/`GamepadWire`; while streaming, EVERY element's
`preferredSystemGestureState` is claimed `.disabled` — share/create reaches the host as
select instead of screenshotting locally, PS/Home reaches the host as guide/`BTN_MODE` =
the Steam-overlay button — restored `.enabled` on unbind), feedback rendering (rumble → CoreHaptics; lightbar /
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/ player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser). `GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
motion sign/scale derived, not yet live-verified. **Gamepad UI (iOS/iPadOS + macOS, motion sign/scale derived, not yet live-verified. **Rumble renderer rewritten
(2026-07-02, `RumbleRenderer.swift`)** around "rumble is idempotent state, divergence
must be bounded": the old per-datagram infinite-duration CoreHaptics players could leak
one dropped async `stop` into a forever-buzzing motor (the stuck-rumble-after-menu bug)
— now finite self-expiring segments with seamless engine-timeline re-arm, newest-wins
dry drain of the 0xCA plane (was 1 datagram/8 ms), dedupe of the host's 500 ms state
refreshes, zero-immediate/ramp-throttled rebakes, escalation to `engine.stop()` on a
throwing player stop, and a 1.6 s staleness watchdog (`Policy.session`; the settings
test panel uses `.manual` = hold). Controller engines use **plain `makePlayer` — never
`makeAdvancedPlayer`**: the controller haptics server (gamecontrollerd) advertises
`adv players: 0`, and iOS 27 beta 2 hard-drops advanced-player loads (XPC decode fault →
CoreHaptics -4811/4097, rumble silently dead). Unit-tested (`RumbleTuningTests`);
stuck-rumble repro on-glass revalidation pending. **Gamepad UI (iOS/iPadOS + macOS,
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher 2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add (`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
Host tile (A connect · Y library · X settings · B back), a controller-navigable Host tile (A connect · Y library · X settings · B back), a controller-navigable
@@ -189,7 +204,13 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard "always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
the mode without a pad). Controller-in-hand on-glass validation still pending on all the mode without a pad). Controller-in-hand on-glass validation still pending on all
platforms. Tests: `swift test` in platforms. **Touch input (iOS/iPadOS, 2026-07-02):** a 3-way model in Settings —
**Trackpad** (default; the Android client's gesture vocabulary ported 1:1 in
`Input/TouchMouse.swift`: tap=click · two-finger tap=right-click · two-finger drag=scroll ·
tap-then-drag=held drag · three-finger tap=HUD toggle, relative ballistics with the same
px-based acceleration curve), **Direct pointer** (cursor jumps to the finger), **Touch
passthrough** (the previous always-on behavior — real wire touches). Latched per gesture
from `DefaultsKey.touchMode`; not yet on-glass validated. Tests: `swift test` in
`clients/apple` (unit + real-codec round trip), `clients/apple` (unit + real-codec round trip),
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS; `test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
includes the pairing ceremony + `--require-pairing` gate), includes the pairing ceremony + `--require-pairing` gate),
@@ -335,7 +356,11 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity + the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml` `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish. (`ci/play-upload.py`). Touch input is the same 3-way model as iOS (2026-07-02): the existing
Trackpad/Direct mouse modes plus new **real multi-touch passthrough**
(`streamTouchPassthrough``nativeSendTouch` → wire TouchDown/Move/Up), a `TouchMode`
Settings dropdown replacing the old trackpad Boolean (migrated on load); not yet
on-device validated. Next: real-device gamepad/HDR live-verify, presenter/latency polish.
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct 2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~24 ms NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~24 ms
at high res). at high res).
Generated
+10 -8
View File
@@ -2004,7 +2004,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.5.0" version = "0.5.1"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2136,7 +2136,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2729,7 +2729,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -2743,7 +2743,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2765,7 +2765,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2788,7 +2788,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2818,7 +2818,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -2839,6 +2839,7 @@ dependencies = [
"khronos-egl", "khronos-egl",
"libc", "libc",
"libloading", "libloading",
"log",
"mdns-sd", "mdns-sd",
"nvidia-video-codec-sdk", "nvidia-video-codec-sdk",
"openh264", "openh264",
@@ -2863,6 +2864,7 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
"tower", "tower",
"tracing", "tracing",
"tracing-log",
"tracing-subscriber", "tracing-subscriber",
"ureq", "ureq",
"usbip-sim", "usbip-sim",
@@ -2885,7 +2887,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
+1 -1
View File
@@ -16,7 +16,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"] exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.5.0" version = "0.5.1"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+3 -3
View File
@@ -53,8 +53,8 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test | | **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch | | **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation | | **Windows client** (`clients/windows`, WinUI 3) | Streaming live: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing | | **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio, (RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
@@ -135,7 +135,7 @@ clients/
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio) android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
probe/ headless reference / measurement client for punktfunk/1 probe/ headless reference / measurement client for punktfunk/1
decky/ Steam Deck Decky plugin decky/ Steam Deck Decky plugin
web/ web console (TanStack) over the management API — status · devices · pairing web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
design/ design notes & deep-dive plans (index: design/README.md) design/ design notes & deep-dive plans (index: design/README.md)
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.5.0" "version": "0.5.1"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -33,13 +33,19 @@ data class Settings(
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */ /** Show the live stats overlay (FPS / throughput / latency) during a stream. */
val statsHudEnabled: Boolean = true, val statsHudEnabled: Boolean = true,
/** /**
* Touch input model. `true` (default) = trackpad: the cursor stays put on touch-down and moves * Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
* by the finger's relative delta (swipe to nudge, lift and re-swipe to walk it across), tap to * the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
* click where it is. `false` = direct pointing: the cursor jumps to the finger (the old behaviour). * lift and re-swipe to walk it across), tap to click where it is. [TouchMode.POINTER]: the
* cursor jumps to the finger (direct pointing). [TouchMode.TOUCH]: real multi-touch
* passthrough — every finger reaches the host as a touchscreen contact, for apps/games that
* understand touch. Mirrors the Apple client's TouchInputMode.
*/ */
val trackpadMode: Boolean = true, val touchMode: TouchMode = TouchMode.TRACKPAD,
) )
/** [Settings.touchMode] values; persisted by name. */
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */ /** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val prefs = private val prefs =
@@ -57,7 +63,10 @@ class SettingsStore(context: Context) {
codec = prefs.getString(K_CODEC, "auto") ?: "auto", codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false), micEnabled = prefs.getBoolean(K_MIC, false),
statsHudEnabled = prefs.getBoolean(K_HUD, true), statsHudEnabled = prefs.getBoolean(K_HUD, true),
trackpadMode = prefs.getBoolean(K_TRACKPAD, true), touchMode = prefs.getString(K_TOUCH_MODE, null)
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -73,7 +82,7 @@ class SettingsStore(context: Context) {
.putString(K_CODEC, s.codec) .putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled) .putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled) .putBoolean(K_HUD, s.statsHudEnabled)
.putBoolean(K_TRACKPAD, s.trackpadMode) .putString(K_TOUCH_MODE, s.touchMode.name)
.apply() .apply()
} }
@@ -89,6 +98,9 @@ class SettingsStore(context: Context) {
const val K_CODEC = "codec" const val K_CODEC = "codec"
const val K_MIC = "mic_enabled" const val K_MIC = "mic_enabled"
const val K_HUD = "stats_hud_enabled" const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode" const val K_TRACKPAD = "trackpad_mode"
} }
} }
@@ -195,6 +207,13 @@ val COMPOSITOR_OPTIONS = listOf(
"gamescope", "gamescope",
) )
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
TouchMode.POINTER to "Direct pointer",
TouchMode.TOUCH to "Touch passthrough",
)
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */ /** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
val GAMEPAD_OPTIONS = listOf( val GAMEPAD_OPTIONS = listOf(
"Automatic", "Automatic",
@@ -165,13 +165,21 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
) )
} }
SettingsGroup("Pointer") { SettingsGroup("Touch input") {
ToggleRow( SettingDropdown(
title = "Trackpad mode", label = "Touch input",
subtitle = "Relative cursor like a laptop touchpad — swipe to nudge, tap to click. " + options = TOUCH_MODE_OPTIONS,
"Off = the cursor jumps to your finger.", selected = s.touchMode,
checked = s.trackpadMode, onSelect = { mode -> update(s.copy(touchMode = mode)) },
onCheckedChange = { on -> update(s.copy(trackpadMode = on)) }, )
Text(
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger " +
"tap right-clicks, two fingers scroll, tap-then-drag holds the button. " +
"Direct pointer: the cursor jumps to your finger. Touch passthrough: real " +
"multi-touch reaches the host, for apps that understand touch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
) )
} }
@@ -57,7 +57,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
var stats by remember { mutableStateOf<DoubleArray?>(null) } var stats by remember { mutableStateOf<DoubleArray?>(null) }
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) } var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes). // Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val trackpad = initialSettings.trackpadMode val touchMode = initialSettings.touchMode
LaunchedEffect(handle, showStats) { LaunchedEffect(handle, showStats) {
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats) NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
if (showStats) { if (showStats) {
@@ -148,11 +148,18 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (showStats) { if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) } stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
} }
// Touch → mouse (trackpad vs. direct pointing + the shared gesture vocabulary — see // Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// streamTouchInput in TouchInput.kt). // vocabulary) or real multi-touch passthrough — see TouchInput.kt.
Box( Box(
Modifier.fillMaxSize().pointerInput(handle, trackpad) { Modifier.fillMaxSize().pointerInput(handle, touchMode) {
streamTouchInput(handle, trackpad, onToggleStats = { showStats = !showStats }) when (touchMode) {
TouchMode.TOUCH -> streamTouchPassthrough(handle)
else -> streamTouchInput(
handle,
trackpad = touchMode == TouchMode.TRACKPAD,
onToggleStats = { showStats = !showStats },
)
}
}, },
) )
} }
@@ -2,7 +2,11 @@ package io.unom.punktfunk
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChanged
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.hypot import kotlin.math.hypot
@@ -38,6 +42,54 @@ private const val ACCEL_MAX = 3.0f
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving * two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onToggleStats] (the stats HUD). * windows); three-finger tap = [onToggleStats] (the stats HUD).
*/ */
/**
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
* contact (down/move/up with a stable per-finger id), with NO gesture interpretation — taps,
* drags and multi-finger input mean whatever the remote app decides. Coordinates are overlay
* pixels with the overlay size as the surface, exactly like the absolute-mouse path (the host
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
* contact is lifted so nothing stays stuck on the host.
*/
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
val ids = mutableMapOf<PointerId, Int>()
fun alloc(p: PointerId): Int {
var id = 0
while (ids.containsValue(id)) id++
ids[p] = id
return id
}
try {
awaitPointerEventScope {
while (true) {
val ev = awaitPointerEvent()
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) continue
for (c in ev.changes) {
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
when {
c.changedToDownIgnoreConsumed() ->
NativeBridge.nativeSendTouch(handle, alloc(c.id), 0, x, y, sw, sh)
c.changedToUpIgnoreConsumed() ->
ids.remove(c.id)?.let {
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
}
c.positionChanged() ->
ids[c.id]?.let {
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
}
}
c.consume()
}
}
}
} finally {
// Lift anything still down (composition/session teardown mid-touch).
ids.values.forEach { NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, 1, 1) }
}
}
internal suspend fun PointerInputScope.streamTouchInput( internal suspend fun PointerInputScope.streamTouchInput(
handle: Long, handle: Long,
trackpad: Boolean, trackpad: Boolean,
@@ -27,6 +27,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.unom.punktfunk.BrandDark import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.Settings import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsScreen import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.HostCard
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
gamepad = 2, gamepad = 2,
micEnabled = true, micEnabled = true,
statsHudEnabled = true, statsHudEnabled = true,
trackpadMode = true, touchMode = TouchMode.TRACKPAD,
), ),
onChange = {}, onChange = {},
onBack = {}, onBack = {},
@@ -159,6 +159,22 @@ object NativeBridge {
/** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */ /** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */
external fun nativeSendScroll(handle: Long, axis: Int, delta: Int) external fun nativeSendScroll(handle: Long, axis: Int, delta: Int)
/**
* One REAL touchscreen transition (the touch-passthrough input mode). [kind]: 0=down 1=move
* 2=up. [id] distinguishes fingers and is reusable after up; coordinates are pixels on the
* client's touch surface — the host rescales against [surfaceWidth]×[surfaceHeight] and
* injects a real touch contact. On up only [id] matters.
*/
external fun nativeSendTouch(
handle: Long,
id: Int,
kind: Int,
x: Int,
y: Int,
surfaceWidth: Int,
surfaceHeight: Int,
)
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */ /** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int) external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
@@ -93,6 +93,34 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
send_event(handle, InputKind::MouseScroll, axis as u32, delta, 0, 0); send_event(handle, InputKind::MouseScroll, axis as u32, delta, 0, 0);
} }
/// `NativeBridge.nativeSendTouch(handle, id, kind, x, y, surfaceWidth, surfaceHeight)` — one REAL
/// touchscreen transition (`kind`: 0=down 1=move 2=up), for the touch-passthrough input mode. `id`
/// distinguishes fingers (reusable after up); coordinates are pixels on the client's touch
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
/// (libei touchscreen / wlroots / SendInput).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
_env: JNIEnv,
_this: JObject,
handle: jlong,
id: jint,
kind: jint,
x: jint,
y: jint,
surface_width: jint,
surface_height: jint,
) {
let kind = match kind {
0 => InputKind::TouchDown,
1 => InputKind::TouchMove,
_ => InputKind::TouchUp,
};
let w = (surface_width.max(0) as u32) & 0xffff;
let h = (surface_height.max(0) as u32) & 0xffff;
send_event(handle, kind, id as u32, x, y, (w << 16) | h);
}
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows /// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier /// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves). /// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
@@ -255,6 +255,10 @@ struct ControllerTestView: View {
Toggle("Light motor (right)", isOn: $lightOn) Toggle("Light motor (right)", isOn: $lightOn)
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform") Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary) .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
if let problem = tester.rumbleHealth {
Label(problem, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
}
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency " Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics " + "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
+ "can't reach its motors on macOS).") + "can't reach its motors on macOS).")
@@ -201,25 +201,36 @@ extension SettingsView {
} }
#if os(iOS) #if os(iOS)
/// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs /// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
/// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock /// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
/// the mouse path there is always the absolute fallback).
@ViewBuilder var pointerSection: some View { @ViewBuilder var pointerSection: some View {
if UIDevice.current.userInterfaceIdiom == .pad { let isPad = UIDevice.current.userInterfaceIdiom == .pad
Section { Section {
Toggle("Capture pointer for games", isOn: $pointerCapture) Picker("Touch input", selection: $touchMode) {
} header: { Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
Text("Pointer") Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
} footer: { Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
Text("With a mouse or trackpad connected, lock the pointer and send relative "
+ "movement — the expected behavior for games (mouse-look). Turn this off for "
+ "desktop use to keep the pointer free and send its absolute position instead. "
+ "The lock needs the stream full-screen and frontmost; it falls back to the "
+ "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is "
+ "unaffected. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
} }
if isPad {
Toggle("Capture pointer for games", isOn: $pointerCapture)
}
} header: {
Text("Touch & pointer")
} footer: {
Text("Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
+ "click, two-finger tap for a right click, two-finger drag to scroll, "
+ "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
+ "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
+ "multi-touch reaches the host, for apps that understand touch. Applies from "
+ "the next touch."
+ (isPad
? " Pointer capture locks a hardware mouse/trackpad for relative movement "
+ "(mouse-look); off keeps the pointer free and sends absolute positions. "
+ "The lock needs the stream full-screen and frontmost, and falls back "
+ "automatically (Stage Manager, Slide Over)."
: ""))
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
} }
} }
#endif #endif
@@ -43,6 +43,7 @@ struct SettingsView: View {
#endif #endif
#if os(iOS) #if os(iOS)
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true @AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
// Width class decides the initial value: nil on iPhone (show the category list first), // Width class decides the initial value: nil on iPhone (show the category list first),
// General on iPad (a two-column layout should never open with an empty detail). // General on iPad (a two-column layout should never open with an empty detail).
@@ -10,13 +10,20 @@ import GameController
/// a passing test exercises the exact code a session runs. /// a passing test exercises the exact code a session runs.
@MainActor @MainActor
public final class ControllerTester: ObservableObject { public final class ControllerTester: ObservableObject {
private let renderer = RumbleRenderer() // `.manual`: the panel's toggles hold a level until changed no session wire refreshes
// exist here to keep the renderer's staleness watchdog fed.
private let renderer = RumbleRenderer(policy: .manual)
private weak var controller: GCController? private weak var controller: GCController?
/// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or "" /// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or ""
/// for the test panel to display so it's obvious which path a given pad takes. /// for the test panel to display so it's obvious which path a given pad takes.
@Published public private(set) var rumbleBackend = "" @Published public private(set) var rumbleBackend = ""
/// Why rumble structurally cannot work right now (nil = healthy) e.g. the device's
/// haptics service refusing every connection, or a pad with no rumble engine. Shown by the
/// test panel so silence diagnoses itself instead of reading as an app bug.
@Published public private(set) var rumbleHealth: String?
public init() {} public init() {}
/// Aim the feedback at a controller (nil releases it). Idempotent safe to call on every /// Aim the feedback at a controller (nil releases it). Idempotent safe to call on every
@@ -24,9 +31,14 @@ public final class ControllerTester: ObservableObject {
public func target(_ c: GCController?) { public func target(_ c: GCController?) {
guard c !== controller else { return } guard c !== controller else { return }
controller = c controller = c
renderer.retarget(c) { [weak self] note in renderer.retarget(
Task { @MainActor in self?.rumbleBackend = note } c,
} onBackend: { [weak self] note in
Task { @MainActor in self?.rumbleBackend = note }
},
onHealth: { [weak self] problem in
Task { @MainActor in self?.rumbleHealth = problem }
})
} }
/// Drive both motors at 0...1 amplitudes low = left/heavy, high = right/light mapped to /// Drive both motors at 0...1 amplitudes low = left/heavy, high = right/light mapped to
@@ -102,6 +102,13 @@ public final class GamepadCapture {
tp?.primary.valueChangedHandler = nil tp?.primary.valueChangedHandler = nil
tp?.secondary.valueChangedHandler = nil tp?.secondary.valueChangedHandler = nil
} }
// Hand the system gestures back to the OS before letting the old pad go outside a
// stream the share button's screenshot and the Home overlay are the user's, not ours.
if let old = bound {
for element in old.physicalInputProfile.elements.values {
element.preferredSystemGestureState = .enabled
}
}
if let motion = bound?.motion { if let motion = bound?.motion {
motion.valueChangedHandler = nil motion.valueChangedHandler = nil
// Power the sensors back down left active they keep the pad streaming // Power the sensors back down left active they keep the pad streaming
@@ -114,14 +121,21 @@ public final class GamepadCapture {
ext.valueChangedHandler = { [weak self] g, _ in ext.valueChangedHandler = { [weak self] g, _ in
MainActor.assumeIsolated { self?.sync(g) } MainActor.assumeIsolated { self?.sync(g) }
} }
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit). On // Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
// macOS the SYSTEM grabs it by default (opens Launchpad's Games folder), so it never reached // gestures to several controller buttons share/create local screenshot/recording,
// the app `preferredSystemGestureState = .disabled` on the element is what hands it to us. // Home Game Center overlay (iOS) / Launchpad's Games folder (macOS) and with a
// We drive `guide` DIRECTLY from this handler's pressed value (not via buttonMask), because // gesture attached the press is the system's, not the game's. During capture the remote
// the legacy `extendedGamepad.buttonHome` is unreliable/often nil even when the physical // session IS the game: the share button must reach the host (e.g. Steam screenshots),
// element exists. On tvOS the element is absent (reserved) nil, the whole block no-ops. // the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
for element in c.physicalInputProfile.elements.values {
element.preferredSystemGestureState = .disabled
}
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit,
// BTN_MODE on the virtual xpad the Steam-overlay button). Driven DIRECTLY from this
// handler's pressed value (not via buttonMask), because the legacy
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
// exists. On tvOS the element is absent (reserved) nil, the whole block no-ops.
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] { if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
home.preferredSystemGestureState = .disabled
home.pressedChangedHandler = { [weak self] _, _, pressed in home.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.sendGuide(down: pressed) } MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
} }
@@ -192,6 +206,11 @@ public final class GamepadCapture {
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight } if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
if g.buttonMenu.isPressed { b |= GamepadWire.start } if g.buttonMenu.isPressed { b |= GamepadWire.start }
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
// the create button BOTH as buttonOptions and as the share element this OR is harmless
// same wire bit.
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick } if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
@@ -25,7 +25,7 @@ public final class GamepadFeedback {
private let flag = StopFlag() private let flag = StopFlag()
private let drainDone = DispatchSemaphore(value: 0) private let drainDone = DispatchSemaphore(value: 0)
private var drainStarted = false private var drainStarted = false
private let rumble = RumbleRenderer() private let rumble = RumbleRenderer(policy: .session)
private var activeSub: AnyCancellable? private var activeSub: AnyCancellable?
// Last applied feedback (main-actor) replayed when the active controller changes. // Last applied feedback (main-actor) replayed when the active controller changes.
@@ -82,8 +82,21 @@ public final class GamepadFeedback {
// poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR // poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps // meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
// rumble/HID latency low while leaving the lock free between polls. // rumble/HID latency low while leaving the lock free between polls.
if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 { //
self?.rumble.apply(low: r.low, high: r.high) // Rumble is idempotent state, so drain the plane DRY and apply only the newest
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
// and its drop-newest overflow could shed a stop while stale nonzero states
// queued ahead of it buzzing until the host's next 500 ms refresh.
var newest: (low: UInt16, high: UInt16)?
var rumbleBurst = 0
while rumbleBurst < 64, !flag.isStopped,
let r = try connection.nextRumble(timeoutMs: 0) {
if r.pad == 0 { newest = (r.low, r.high) }
rumbleBurst += 1
}
if let n = newest {
self?.rumble.apply(low: n.low, high: n.high)
} }
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing // Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle. // per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
@@ -5,28 +5,145 @@ import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad") private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Rumble CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound, /// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero /// policy is unit-testable without a `CHHapticEngine` or a physical pad.
/// amplitude and torn down on retarget; players run only while their motor is on, so an enum RumbleTuning {
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets) /// Haptic segment length. **No event is ever infinite**: a player the renderer loses track
/// downgrade to silence rumble is best-effort by design. /// of (a stop dropped inside CoreHaptics, an engine race) self-silences when its segment
/// /// expires, so this is the hard ceiling on how long the actuator can diverge from the
/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is /// target state.
/// read and written only inside `queue` closures the serial queue is the synchronization. static let segmentSeconds: TimeInterval = 4.0
final class RumbleRenderer: @unchecked Sendable { /// Re-arm the successor segment once the current one has less than this left. Generous
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) /// against the ticker period so a steady rumble can never miss the boundary and gap.
static let rearmHeadroom: TimeInterval = 1.0
/// Renderer ticker period while anything is (or should be) audible. Silence runs no timer.
static let tickSeconds: TimeInterval = 0.05
/// Minimum spacing between player rebuilds for nonzerononzero level changes a game
/// ramping rumble per frame would otherwise stop/start players at 60+ Hz, which is exactly
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
/// zero is never throttled.
static let minRebakeSeconds: TimeInterval = 0.025
/// Session watchdog: silence the motors when no wire command arrived for this long. The
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
/// after 3 consecutive refreshes vanished i.e. the channel or host died while audible.
static let sessionStaleSeconds: TimeInterval = 1.6
/// Levels closer than this (0.4 % of full scale) are the same level an identical host
/// refresh must never rebuild a player.
static let levelEpsilon: Float = 1.0 / 256.0
/// macOS DualSense raw-HID path: re-write an unchanged nonzero level this often so the
/// pad's firmware never times the rumble out mid-effect (Bluetooth pads watchdog output
/// reports), and a dropped report heals.
static let hidKeepaliveSeconds: TimeInterval = 0.9
/// One actuator's started engine plus the player currently driving it (nil = idle). The /// `CHHapticEvent` sharpness = actuator frequency. A DualSense's voice-coil motors need a
/// player is rebuilt per level change `drive` bakes the target intensity into a fresh /// defined frequency to move at all (an intensity-only event left them silent) while a
/// continuous event rather than scaling a long-lived one with a dynamic parameter. /// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
/// distinct frequencies mirroring the real hardware they emulate low/left the heavy
/// low-frequency rotor, high/right the light buzzer; a single combined actuator keeps the
/// proven mid value.
static let sharpnessLow: Float = 0.3
static let sharpnessHigh: Float = 0.7
static let sharpnessCombined: Float = 0.5
/// Wire amplitude (0...0xFFFF) CoreHaptics intensity (0...1).
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
/// Wire amplitude DualSense HID motor byte.
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
/// Single-actuator pads render whichever motor is stronger.
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
/// Are two baked levels the same (skip the rebuild)?
static func sameLevel(_ a: Float, _ b: Float) -> Bool { abs(a - b) <= levelEpsilon }
/// Time for a segment handoff to act (engine timeline).
static func shouldRearm(endsAt: TimeInterval, now: TimeInterval) -> Bool {
endsAt - now <= rearmHeadroom
}
/// When the successor segment starts: exactly as the current one expires unless that
/// already passed (the gap already happened; start now).
static func handoffStart(endsAt: TimeInterval, now: TimeInterval) -> TimeInterval {
max(endsAt, now)
}
}
/// Rumble the active physical controller (CoreHaptics; a DualSense on macOS goes over raw HID
/// instead, see `DualSenseHID`), built around one principle: **rumble is idempotent state on a
/// lossy channel, and the actuator's divergence from that state must be bounded** not
/// best-effort. The previous renderer drove infinite-duration players torn down and rebuilt per
/// wire update; one asynchronous `stop` dropped inside CoreHaptics left an unstoppable player
/// buzzing with its handle discarded, which no later (0,0) could reach the "walked into the
/// menu and the rumble never stopped" bug.
///
/// The invariants that bound divergence now:
/// 1. **No infinite events.** A motor plays finite `segmentSeconds` segments; while the level
/// holds, the successor is scheduled ON the engine timeline to start exactly when the
/// current segment expires (seamless no stop/start race in steady state). A leaked player
/// therefore self-silences in `segmentSeconds`.
/// 2. **Idempotent targets.** An update equal to the current target (the host re-sends rumble
/// state every 500 ms as its loss heal) is a liveness stamp, never a player rebuild.
/// 3. **Zero is immediate, ramps are throttled.** (0,0) stops players the moment it lands;
/// nonzerononzero changes rebuild at most every `minRebakeSeconds` per motor (the ticker
/// lands the newest value once the window opens).
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown the
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
/// exponential backoff.
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
/// `sessionStaleSeconds` force silence. A lost stop can outlive the host's 500 ms heal
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
/// (the settings test panel) instead holds a level until it is changed.
///
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
/// failures (pads without haptics, engine resets) downgrade to silence rumble is best-effort
/// by design, but *staying silent* when told to stop is not.
///
/// `@unchecked Sendable` is sound because every property is read and written only inside
/// `queue` closures the serial queue is the synchronization.
final class RumbleRenderer: @unchecked Sendable {
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
/// slider level indefinitely.
struct Policy {
let staleAfter: TimeInterval?
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
static let manual = Policy(staleAfter: nil)
}
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private let policy: Policy
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
/// advertises `adv players: 0`, and as of iOS 27 beta 2 an advanced-player sequence load
/// doesn't degrade gracefully there the daemon faults decoding the XPC message and drops
/// it (CoreHaptics -4811/4097, rumble dead). We only need `start(atTime:)`/`stop(atTime:)`,
/// which the plain protocol has.
private struct Segment {
let player: CHHapticPatternPlayer
let endsAt: TimeInterval
}
/// One actuator's started engine and the segment(s) realizing `level` on it. `retiring` is
/// the predecessor across a segment handoff left to expire naturally (its successor
/// starts the instant it ends), but the reference is held so a level change or stop can
/// still force-stop it.
private struct Motor { private struct Motor {
let engine: CHHapticEngine let engine: CHHapticEngine
var player: CHHapticAdvancedPatternPlayer? let sharpness: Float
var level: Float = 0
var current: Segment?
var retiring: Segment?
var lastRebake = DispatchTime(uptimeNanoseconds: 0)
} }
private var controller: GCController? private var controller: GCController?
private var low: Motor? private var low: Motor?
private var high: Motor? private var high: Motor?
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
private var target: (low: UInt16, high: UInt16) = (0, 0)
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
private var ticker: DispatchSourceTimer?
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad // `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
// on an OS that doesn't expose rumble through GameController, a Siri Remote) nothing to retry // on an OS that doesn't expose rumble through GameController, a Siri Remote) nothing to retry
// until the controller changes. A transient engine failure does NOT latch it; it tears down for // until the controller changes. A transient engine failure does NOT latch it; it tears down for
@@ -39,86 +156,277 @@ final class RumbleRenderer: @unchecked Sendable {
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble // break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
// update immediately rebuilds into the same dead connection, flooding the log and never // update immediately rebuilds into the same dead connection, flooding the log and never
// recovering. Delay the next setup() growing 0.5124 s on repeated failure and clear it // recovering. Delay the next setup() growing 0.5124 s on repeated failure and clear it
// the moment a player runs cleanly (or the controller changes). // the moment a player is actually running (or the controller changes).
private var retryAfter = Date.distantPast private var retryAfter = DispatchTime(uptimeNanoseconds: 0)
private var consecutiveFailures = 0 private var consecutiveFailures = 0
/// Downgrade after split-handle engines fail: retry with ONE combined `.default` engine
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a /// the configuration virtually every iOS game (and this app's own menu haptics) uses before
/// defined frequency to move at all an intensity-only event (no sharpness) left them /// treating the service as unreachable. A haptics daemon that mishandles per-handle
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid /// localities for a particular pad can still serve the combined engine. One-way per
/// value the known-working macOS DualSense rumble implementations use. (Used only on the /// controller; retarget resets it.
/// CoreHaptics path a DualSense on macOS is driven over raw HID instead, see below.) private var preferCombined = false
private static let sharpness: Float = 0.5 /// Health reporting for the debug test panel: a human-readable problem while rumble cannot
/// work (nil = healthy). Without this, a wedged system haptics service (gamecontrollerd
/// refusing every XPC connection CoreHaptics -4811/4097, which no in-app retry can fix)
/// reads as "the app's rumble is broken" when actually no app on the device can rumble.
private var healthSink: ((String?) -> Void)?
private var lastHealth: String?
#if os(macOS) #if os(macOS)
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics /// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
/// does not reach them on macOS adaptive triggers/lightbar work, rumble is silent). nil for /// does not reach them on macOS adaptive triggers/lightbar work, rumble is silent). nil for
/// every other controller, which keeps the CoreHaptics path. /// every other controller, which keeps the CoreHaptics path.
private var dualSenseHID: DualSenseHID? private var dualSenseHID: DualSenseHID?
private var lastHidWrite: (levels: (UInt8, UInt8), at: DispatchTime) =
((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif #endif
init(policy: Policy = .session) {
self.policy = policy
}
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the /// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
/// rumble backend now in use for the debug controller-test panel. /// rumble backend now in use; `onHealth` with a problem description whenever rumble transitions
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) { /// between working and structurally failing (nil = healthy) both for the debug test panel.
func retarget(
_ c: GCController?, onBackend: ((String) -> Void)? = nil,
onHealth: ((String?) -> Void)? = nil
) {
queue.async { queue.async {
self.teardown() self.teardown()
self.closeHID() self.closeHID()
self.controller = c self.controller = c
self.broken = false self.broken = false
self.preferCombined = false
self.consecutiveFailures = 0 self.consecutiveFailures = 0
self.retryAfter = .distantPast self.retryAfter = DispatchTime(uptimeNanoseconds: 0)
if let onHealth { self.healthSink = onHealth }
self.lastHealth = nil
self.healthSink?(nil)
_ = self.openHIDIfDualSense(c) _ = self.openHIDIfDualSense(c)
onBackend?(self.backendNote(for: c)) onBackend?(self.backendNote(for: c))
// The target survives the swap: render replays the current level onto the new pad
// right away (a mid-rumble controller change keeps rumbling, like moving a real pad
// between hands mid-effect).
self.render()
} }
} }
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
/// free (invariant 2).
func apply(low lowAmp: UInt16, high highAmp: UInt16) { func apply(low lowAmp: UInt16, high highAmp: UInt16) {
queue.async { queue.async {
self.lastCommand = .now()
let active = lowAmp != 0 || highAmp != 0 let active = lowAmp != 0 || highAmp != 0
if active != self.wasActive { if active != self.wasActive {
self.wasActive = active self.wasActive = active
log.debug( log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)") "rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
} }
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every guard (lowAmp, highAmp) != self.target else { return }
// other pad (and for a DualSense whose HID device could not be opened). self.target = (lowAmp, highAmp)
if self.hidRumble(low: lowAmp, high: highAmp) { return } self.render()
guard !self.broken else { return }
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
self.setup()
}
let ok: Bool
if self.high != nil {
// Per-handle: low = left/heavy motor, high = right/light the XInput convention
// the wire carries.
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
ok = okLow && okHigh
} else {
// Combined engine: whichever motor is stronger wins.
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
}
// Rebuild on the next nonzero amplitude if an engine errored and tear down OUTSIDE
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
// update; once a player is actually running the path has recovered, so clear the backoff.
if !ok {
self.teardown()
self.scheduleRetryBackoff()
} else if self.low?.player != nil || self.high?.player != nil {
self.consecutiveFailures = 0
self.retryAfter = .distantPast
}
} }
} }
/// Silence the motors and drop the engines. Blocks until done call off the main actor.
func stop() { func stop() {
queue.sync { queue.sync {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0)
self.wasActive = false
self.teardown() self.teardown()
self.closeHID() self.closeHID()
} }
} }
// MARK: - Reconciliation (all on `queue`)
/// Drive the actuators toward `target`. Idempotent safe to call from every wire update,
/// tick, and retarget; when everything already matches it does nothing.
private func render() {
defer { updateTicker() }
if renderHID() { return }
guard !broken else { return }
let audible = target.low != 0 || target.high != 0
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
setup()
}
// Reconcile BOTH motors (no short-circuit skipping the second on a first-motor error),
// and tear down OUTSIDE the `inout` accesses so teardown() never mutates a motor a
// reconcile call still holds an exclusive reference to.
let ok: Bool
if high != nil {
// Per-handle: low = left/heavy motor, high = right/light the XInput convention
// the wire carries.
let okLow = reconcile(&low, to: RumbleTuning.amplitude(target.low))
let okHigh = reconcile(&high, to: RumbleTuning.amplitude(target.high))
ok = okLow && okHigh
} else {
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
}
if !ok {
let wasSplit = high != nil
teardown()
scheduleRetryBackoff()
if wasSplit, !preferCombined {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
} else if low?.current != nil || high?.current != nil {
// A player is actually running the path has recovered; clear the backoff.
consecutiveFailures = 0
retryAfter = DispatchTime(uptimeNanoseconds: 0)
reportHealth(nil)
}
}
/// Publish a health transition to the test panel (deduped transitions only).
private func reportHealth(_ problem: String?) {
guard problem != lastHealth else { return }
lastHealth = problem
healthSink?(problem)
}
/// Watchdog + housekeeping heartbeat while audible.
private func tick() {
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
// The host refreshes rumble state every 500 ms; this much silence means the channel
// (or host) died while a motor was on. A direct-connected pad would have been
// stopped by its game long ago force the same outcome.
log.warning(
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
target = (0, 0)
}
render()
}
/// Drive one motor toward `desired`, per the invariants above. Returns false when the
/// engine errored the caller then tears everything down (outside this `inout` access) for
/// a lazy, backoff-gated rebuild.
private func reconcile(_ slot: inout Motor?, to desired: Float) -> Bool {
guard var m = slot else { return true }
defer { slot = m }
// Release a handed-off predecessor once it has expired on its own.
if let r = m.retiring, m.engine.currentTime >= r.endsAt + 0.25 {
m.retiring = nil
}
if desired <= RumbleTuning.levelEpsilon {
guard m.level > 0 || m.current != nil || m.retiring != nil else { return true }
m.level = 0
return stopSegments(&m)
}
if RumbleTuning.sameLevel(desired, m.level), m.current != nil {
return rearmIfNeeded(&m)
}
// Nonzero level change. Throttled: the ticker re-runs render() and lands the newest
// value once the window opens (zero above is never throttled).
if m.current != nil, seconds(since: m.lastRebake) < RumbleTuning.minRebakeSeconds {
return true
}
guard stopSegments(&m) else { return false }
do {
m.current = try makeSegment(
m.engine, sharpness: m.sharpness, amplitude: desired, at: CHHapticTimeImmediate)
m.level = desired
m.lastRebake = .now()
return true
} catch {
// A transient failure (the engine stopped/reset between its handler firing and now).
// Signal a rebuild do NOT latch rumble off for the session.
log.warning("rumble: haptic start failed — rebuilding: \(error, privacy: .public)")
return false
}
}
/// Keep a steady level seamless across the finite-segment boundary: when the current
/// segment nears its end, start the successor ON the engine timeline exactly as it expires
/// no stop call, no race, no gap. The old segment is kept as `retiring` until it dies
/// naturally, so a level change can still force-stop it.
private func rearmIfNeeded(_ m: inout Motor) -> Bool {
guard let cur = m.current else { return true }
let now = m.engine.currentTime
guard RumbleTuning.shouldRearm(endsAt: cur.endsAt, now: now) else { return true }
// A predecessor still held this deep into the segment already expired; drop it.
m.retiring = nil
do {
let next = try makeSegment(
m.engine, sharpness: m.sharpness, amplitude: m.level,
at: RumbleTuning.handoffStart(endsAt: cur.endsAt, now: now))
m.retiring = m.current
m.current = next
return true
} catch {
log.warning("rumble: segment re-arm failed — rebuilding: \(error, privacy: .public)")
return false
}
}
/// Stop every segment on the motor NOW. False = a stop threw, so the engine's real state is
/// unknown (a player may still run with its handle gone) the caller must escalate to a
/// full engine teardown, whose `engine.stop()` silences every player the engine hosts.
private func stopSegments(_ m: inout Motor) -> Bool {
var ok = true
for seg in [m.current, m.retiring].compactMap({ $0 }) {
do {
try seg.player.stop(atTime: CHHapticTimeImmediate)
} catch {
log.warning(
"rumble: player stop failed — escalating to engine stop: \(error, privacy: .public)")
ok = false
}
}
m.current = nil
m.retiring = nil
return ok
}
/// Build + start one finite continuous event at `amplitude`. `at` is `CHHapticTimeImmediate`
/// or an absolute engine-timeline instant (a scheduled handoff). The intensity is BAKED into
/// the event: a fixed event scaled by a dynamic `.hapticIntensityControl` parameter drives
/// the iPhone Taptic Engine but is silent on a controller's haptic engine.
private func makeSegment(
_ engine: CHHapticEngine, sharpness: Float, amplitude: Float, at start: TimeInterval
) throws -> Segment {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
],
relativeTime: 0,
duration: RumbleTuning.segmentSeconds)
let player = try engine.makePlayer(
with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: start)
let begins = start == CHHapticTimeImmediate ? engine.currentTime : start
return Segment(player: player, endsAt: begins + RumbleTuning.segmentSeconds)
}
/// The ticker runs only while something needs tending any nonzero target (watchdog,
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
private func updateTicker() {
let needed = target != (0, 0)
|| low?.current != nil || low?.retiring != nil
|| high?.current != nil || high?.retiring != nil
if needed, ticker == nil {
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(
deadline: .now() + RumbleTuning.tickSeconds, repeating: RumbleTuning.tickSeconds)
t.setEventHandler { [weak self] in self?.tick() }
t.resume()
ticker = t
} else if !needed, let t = ticker {
t.cancel()
ticker = nil
}
}
// MARK: - Engine lifecycle
/// Engines per handle when the pad distinguishes them (low = left/heavy motor, /// Engines per handle when the pad distinguishes them (low = left/heavy motor,
/// high = right/light the Xbox/XInput convention the wire carries); one combined /// high = right/light the Xbox/XInput convention the wire carries); one combined
/// engine otherwise, driven by whichever amplitude is stronger. /// engine otherwise, driven by whichever amplitude is stronger.
@@ -130,20 +438,28 @@ final class RumbleRenderer: @unchecked Sendable {
// the controller changes; latch off (retarget clears it) and say so once. // the controller changes; latch off (retarget clears it) and say so once.
log.info("rumble: active controller exposes no haptics engine — rumble unavailable") log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
broken = true broken = true
reportHealth("This controller exposes no rumble engine to apps on this OS.")
return return
} }
let localities = haptics.supportedLocalities let localities = haptics.supportedLocalities
if localities.contains(.leftHandle), localities.contains(.rightHandle) { let split =
low = makeMotor(haptics, .leftHandle) !preferCombined && localities.contains(.leftHandle)
high = makeMotor(haptics, .rightHandle) && localities.contains(.rightHandle)
if split {
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
} else { } else {
low = makeMotor(haptics, .default) low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
} }
if low == nil, high == nil { if low == nil, high == nil {
// Haptics present but no engine could be built right now (server busy / XPC broken). Do // Haptics present but no engine could be built right now (server busy / XPC broken). Do
// NOT latch broken back off and the next nonzero amplitude past the cooldown retries. // NOT latch broken back off and a later render past the cooldown retries.
log.warning("rumble: haptics present but engine setup failed — backing off, will retry") log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
scheduleRetryBackoff() scheduleRetryBackoff()
if split {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
} }
} }
@@ -153,10 +469,20 @@ final class RumbleRenderer: @unchecked Sendable {
private func scheduleRetryBackoff() { private func scheduleRetryBackoff() {
consecutiveFailures += 1 consecutiveFailures += 1
let shift = min(consecutiveFailures - 1, 4) let shift = min(consecutiveFailures - 1, 4)
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4)) retryAfter = .now() + min(0.5 * Double(1 << shift), 4)
if consecutiveFailures >= 2 {
// One failure is a hiccup; repeated ones are the wedged-service signature (every
// XPC connection to gamecontrollerd.haptics breaks no app on the device can
// rumble until it relaunches). Say so instead of failing silently.
reportHealth(
"The system haptics service is refusing connections — no app can rumble a "
+ "controller right now. Rebooting the device usually clears it.")
}
} }
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? { private func makeMotor(
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil } guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session // A controller's motors carry no audio, so keep this engine OUT of the app's audio session
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time; // (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
@@ -167,7 +493,8 @@ final class RumbleRenderer: @unchecked Sendable {
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left // audio-session interruption (a call, Siri, another audio app), or a server crash. Left
// unhandled the players go dead and every later rumble throws, latching rumble off for the // unhandled the players go dead and every later rumble throws, latching rumble off for the
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the // rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
// serial queue so the next nonzero amplitude lazily rebuilds the engine, instead. // serial queue; the ticker (or the next wire update) lazily rebuilds the engine and
// re-renders the still-current target.
engine.stoppedHandler = { [weak self] reason in engine.stoppedHandler = { [weak self] reason in
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild") log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
self?.queue.async { self?.teardown() } self?.queue.async { self?.teardown() }
@@ -177,72 +504,42 @@ final class RumbleRenderer: @unchecked Sendable {
self?.queue.async { self?.teardown() } self?.queue.async { self?.teardown() }
} }
do { do {
// Start the engine now; the player that actually moves the motor is built per level // Start the engine now; the players that actually move the motor are the finite
// change in `drive` (a fresh event baked at the target intensity). // segments `reconcile` bakes per level.
try engine.start() try engine.start()
return Motor(engine: engine, player: nil) return Motor(engine: engine, sharpness: sharpness)
} catch { } catch {
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)") log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
return nil return nil
} }
} }
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
/// duration so a single host update the host sends rumble only when the level changes
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
guard var m = motor else { return true }
// Replace any running player: stop the old, and for a zero level leave the motor idle.
try? m.player?.stop(atTime: CHHapticTimeImmediate)
m.player = nil
guard amplitude > 0 else { motor = m; return true }
do {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
],
relativeTime: 0,
duration: TimeInterval(GCHapticDurationInfinite))
let player = try m.engine.makeAdvancedPlayer(
with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: CHHapticTimeImmediate)
m.player = player
motor = m
return true
} catch {
// A transient failure (the engine stopped/reset between its handler firing and now).
// Signal a rebuild do NOT latch rumble off for the session (the old "spotty" bug).
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
motor = m
return false
}
}
private func teardown() { private func teardown() {
for m in [low, high].compactMap({ $0 }) { for m in [low, high].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them. // Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.) // (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in } m.engine.stoppedHandler = { _ in }
m.engine.resetHandler = {} m.engine.resetHandler = {}
try? m.player?.stop(atTime: CHHapticTimeImmediate) for seg in [m.current, m.retiring].compactMap({ $0 }) {
try? seg.player.stop(atTime: CHHapticTimeImmediate)
}
// The authoritative silencer: a stopped engine plays nothing, including any player
// whose individual stop was dropped.
m.engine.stop() m.engine.stop()
} }
low = nil low = nil
high = nil high = nil
} }
private func seconds(since t: DispatchTime) -> TimeInterval {
TimeInterval(DispatchTime.now().uptimeNanoseconds - t.uptimeNanoseconds) / 1_000_000_000
}
// MARK: - DualSense raw-HID rumble (macOS) // MARK: - DualSense raw-HID rumble (macOS)
// //
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense // On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path. // we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
// All three run on the serial `queue`, like the rest of the renderer state. // Runs on the serial `queue`, like the rest of the renderer state.
private func openHIDIfDualSense(_ c: GCController?) -> Bool { private func openHIDIfDualSense(_ c: GCController?) -> Bool {
#if os(macOS) #if os(macOS)
@@ -256,12 +553,19 @@ final class RumbleRenderer: @unchecked Sendable {
#endif #endif
} }
/// Drive the DualSense's motors over HID if that's the active backend; false not a HID pad, /// Write the target to the DualSense over HID if that's the active backend; false not a
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255. /// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
private func hidRumble(low: UInt16, high: UInt16) -> Bool { /// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
private func renderHID() -> Bool {
#if os(macOS) #if os(macOS)
guard let hid = dualSenseHID else { return false } guard let hid = dualSenseHID else { return false }
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8)) let levels = (RumbleTuning.hidByte(target.low), RumbleTuning.hidByte(target.high))
let keepalive = levels != (0, 0)
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
if levels != lastHidWrite.levels || keepalive {
hid.rumble(low: levels.0, high: levels.1)
lastHidWrite = (levels, .now())
}
return true return true
#else #else
return false return false
@@ -270,8 +574,9 @@ final class RumbleRenderer: @unchecked Sendable {
private func closeHID() { private func closeHID() {
#if os(macOS) #if os(macOS)
dualSenseHID?.close() dualSenseHID?.close() // writes (0,0) before releasing
dualSenseHID = nil dualSenseHID = nil
lastHidWrite = ((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif #endif
} }
@@ -0,0 +1,285 @@
// Finger touches host mouse, for the touchscreen devices: a port of the Android client's
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
// identical. Two mouse modes share one gesture vocabulary tap = left click · two-finger
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
// (text selection / window moves) · three-finger tap = stats-HUD toggle:
//
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
// relative delta with mild acceleration swipe to nudge, lift and re-swipe to walk it
// across, tap to click where it is. This is what makes the cursor reachable on a small
// screen.
// * pointer: the cursor jumps to the finger and follows it (absolute moves through the
// aspect-fit letterbox) direct pointing for desktop-style use.
//
// The third `TouchInputMode` (`touch`) never reaches this type: `StreamLayerUIView` forwards
// those fingers as REAL wire touches (multi-touch passthrough) instead.
#if os(iOS)
import Foundation
import PunktfunkCore
import UIKit
/// How touchscreen fingers drive the host persisted under `DefaultsKey.touchMode`, latched
/// per gesture by `StreamLayerUIView` (a Settings change applies from the NEXT touch, and a
/// gesture never splits across models). `trackpad` is the default: a cursor is the
/// universally workable model; passthrough only helps hosts/apps that actually speak touch.
public enum TouchInputMode: String, CaseIterable, Sendable {
case trackpad
case pointer
case touch
/// The persisted setting, defaulting to trackpad when unset/unknown.
public static var current: TouchInputMode {
TouchInputMode(
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.touchMode) ?? ""
) ?? .trackpad
}
}
/// The gesture state machine behind the two mouse modes. One instance per stream view, fed
/// only the DIRECT touches (fingers/Pencil indirect pointers have their own path). Runs
/// entirely on the main thread (UIKit touch delivery). Touches are tracked by identity key
/// with positions cached per event `UITouch` objects are never retained.
final class TouchMouse {
/// Gesture/ballistics tuning. Distances are in points where they gate gestures; the
/// relative ballistics work in PHYSICAL pixels (point deltas × screen scale) so the
/// acceleration curve matches the Android client's pixel-based constants 1:1.
enum Tuning {
/// Movement under this (pt) still counts as a tap, not a drag.
static let tapSlop: CGFloat = 8
/// A new touch this soon (s) after a tap, near it, starts a held left-button drag.
static let tapDragWindow: TimeInterval = 0.25
/// Two-finger pan distance (pt) per 120-unit wheel notch matches the feel of the
/// indirect-trackpad scroll path in StreamViewIOS (~10 pt per notch).
static let scrollNotchPt: CGFloat = 10
/// Base finger-px host-px gain (~1:1, never twitchy). The acceleration below lets a
/// flick cross the screen while a slow drag stays precise.
static let pointerSens: CGFloat = 1.3
/// Above `accelSpeedFloor` px/ms the gain ramps by `accelGain` per px/ms, capped at
/// `accelMax` (so a fast swipe can't fling the cursor uncontrollably).
static let accelGain: CGFloat = 0.6
static let accelSpeedFloor: CGFloat = 0.3
static let accelMax: CGFloat = 3.0
/// Acceleration multiplier for a finger speed in physical px per ms.
static func accel(forSpeed speed: CGFloat) -> CGFloat {
min(1 + accelGain * max(speed - accelSpeedFloor, 0), accelMax)
}
}
/// Wire events out (the owner gates them on its capture state).
var send: ((PunktfunkInputEvent) -> Void)?
/// View-space point host-mode pixels through the letterbox (pointer mode's moves).
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
/// No gesture in flight (all fingers up) the view uses this to release its mode latch.
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
private var trackpad = true
/// Last known position per active finger (identity key) kept because moved events only
/// carry the CHANGED touches while the scroll centroid needs every finger.
private var lastPos: [ObjectIdentifier: CGPoint] = [:]
private var sessionActive = false
private var startPoint = CGPoint.zero
private var maxFingers = 0
private var moved = false
private var scrolling = false
private var dragHeld = false
// Trackpad relative-motion state: the tracked finger, its last position/time, and the
// sub-pixel remainder so a slow drag isn't lost to integer truncation.
private var trackKey: ObjectIdentifier?
private var prevPoint = CGPoint.zero
private var prevTime: TimeInterval = 0
private var carryX: CGFloat = 0
private var carryY: CGFloat = 0
/// Scroll anchor (centroid) re-anchored every time a notch fires.
private var scrollAnchor = CGPoint.zero
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
private var lastTapUp: TimeInterval = 0
private var lastTapPoint = CGPoint.zero
/// GameStream mouse button ids.
private enum Button { static let left: UInt32 = 1; static let right: UInt32 = 3 }
func began(_ touches: Set<UITouch>, in view: UIView, trackpad: Bool) {
let starting = lastPos.isEmpty
for touch in touches {
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
}
if starting, let first = touches.first {
self.trackpad = trackpad
sessionActive = true
startPoint = first.location(in: view)
maxFingers = 0
moved = false
scrolling = false
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
// button for this whole gesture (laptop-trackpad convention).
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
&& abs(startPoint.x - lastTapPoint.x) < Tuning.tapSlop
&& abs(startPoint.y - lastTapPoint.y) < Tuning.tapSlop
lastTapUp = 0 // consume the arming either way
// Pointer mode jumps the cursor to the finger; trackpad leaves it put (the whole
// point you nudge it with swipes instead).
if !trackpad, let h = hostPoint?(startPoint) {
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
}
if dragHeld { send?(.mouseButton(Button.left, down: true)) }
trackKey = ObjectIdentifier(first)
prevPoint = startPoint
prevTime = first.timestamp
carryX = 0
carryY = 0
}
maxFingers = max(maxFingers, lastPos.count)
}
func moved(_ touches: Set<UITouch>, in view: UIView) {
guard sessionActive else { return }
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
}
if lastPos.count >= 2 {
scrollByCentroid()
} else if !scrolling, let touch = touches.first(where: {
lastPos[ObjectIdentifier($0)] != nil
}) {
singleFinger(touch, in: view)
}
}
func ended(_ touches: Set<UITouch>, in view: UIView) {
guard sessionActive || !lastPos.isEmpty else { return }
var upTime: TimeInterval = 0
for touch in touches {
lastPos.removeValue(forKey: ObjectIdentifier(touch))
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
upTime = max(upTime, touch.timestamp)
}
guard lastPos.isEmpty, sessionActive else { return }
sessionActive = false
if dragHeld {
dragHeld = false
send?(.mouseButton(Button.left, down: false)) // end the drag
} else if !moved {
switch maxFingers {
case 3...:
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android
case 2: // two-finger tap right click
send?(.mouseButton(Button.right, down: true))
send?(.mouseButton(Button.right, down: false))
default: // tap left click (at the cursor's current spot), arm tap-drag
send?(.mouseButton(Button.left, down: true))
send?(.mouseButton(Button.left, down: false))
lastTapUp = upTime
lastTapPoint = startPoint
}
}
}
/// System-cancelled touches (incoming call, gesture takeover): release anything held but
/// never synthesize a click out of a cancellation.
func cancelled(_ touches: Set<UITouch>) {
for touch in touches {
lastPos.removeValue(forKey: ObjectIdentifier(touch))
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
}
if lastPos.isEmpty { abortSession() }
}
/// Session teardown: release anything held on the wire and forget all gesture state.
func reset() {
lastPos.removeAll()
trackKey = nil
abortSession()
lastTapUp = 0
}
private func abortSession() {
if dragHeld {
dragHeld = false
send?(.mouseButton(Button.left, down: false))
}
sessionActive = false
scrolling = false
moved = false
}
// MARK: - Per-event work
/// Two fingers (or more) scroll by the centroid delta; never move the cursor. Fires a
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
/// right scrolls right (the host WHEEL(120) convention).
private func scrollByCentroid() {
let n = CGFloat(lastPos.count)
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
let cy = lastPos.values.reduce(0) { $0 + $1.y } / n
if !scrolling {
scrolling = true
scrollAnchor = CGPoint(x: cx, y: cy)
}
let notchesY = Int32((scrollAnchor.y - cy) / Tuning.scrollNotchPt)
let notchesX = Int32((cx - scrollAnchor.x) / Tuning.scrollNotchPt)
if notchesY != 0 {
send?(.scroll(notchesY * 120))
scrollAnchor.y = cy
moved = true
}
if notchesX != 0 {
send?(.scroll(notchesX * 120, horizontal: true))
scrollAnchor.x = cx
moved = true
}
}
/// One finger (and the gesture never became a scroll dropping back from two fingers to
/// one must not jerk the cursor).
private func singleFinger(_ touch: UITouch, in view: UIView) {
let loc = touch.location(in: view)
if abs(loc.x - startPoint.x) > Tuning.tapSlop || abs(loc.y - startPoint.y) > Tuning.tapSlop {
moved = true
}
guard trackpad else {
if let h = hostPoint?(loc) { // pointer mode: the cursor follows the finger
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
}
return
}
// Relative: move by the finger delta × (sensitivity × acceleration), carrying the
// sub-pixel remainder. Re-anchor (zero delta this frame) if the tracked finger
// changed, so lifting one of several fingers never jumps the cursor.
let key = ObjectIdentifier(touch)
if key != trackKey {
trackKey = key
prevPoint = loc
prevTime = touch.timestamp
return
}
// Ballistics in physical pixels so the curve matches the Android tuning exactly.
let scale = view.window?.screen.scale ?? view.traitCollection.displayScale
let dx = (loc.x - prevPoint.x) * scale
let dy = (loc.y - prevPoint.y) * scale
let dtMs = max((touch.timestamp - prevTime) * 1000, 1)
prevPoint = loc
prevTime = touch.timestamp
let gain = Tuning.pointerSens * Tuning.accel(forSpeed: hypot(dx, dy) / dtMs)
carryX += dx * gain
carryY += dy * gain
let outX = Int32(carryX) // truncates toward zero remainder kept with its sign
let outY = Int32(carryY)
if outX != 0 || outY != 0 {
send?(.mouseMove(dx: outX, dy: outY))
carryX -= CGFloat(outX)
carryY -= CGFloat(outY)
}
}
/// Three-finger tap toggles the stats overlay through the shared `hudEnabled` default,
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them).
private static func toggleHUD() {
let defaults = UserDefaults.standard
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
}
}
#endif
@@ -41,6 +41,11 @@ public enum DefaultsKey {
/// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide /// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide
/// Over). Read by `StreamViewController.prefersPointerLocked`. /// Over). Read by `StreamViewController.prefersPointerLocked`.
public static let pointerCapture = "punktfunk.pointerCapture" public static let pointerCapture = "punktfunk.pointerCapture"
/// iPhone/iPad: how touchscreen fingers drive the host a `TouchInputMode` raw value:
/// "trackpad" (default: relative cursor with tap-click / two-finger-scroll gestures),
/// "pointer" (the cursor jumps to the finger), or "touch" (real multi-touch passthrough).
/// Read live per gesture by `StreamLayerUIView`.
public static let touchMode = "punktfunk.touchMode"
/// Experimental: show the host's game library (browsed over the management API). Off by default. /// Experimental: show the host's game library (browsed over the management API). Off by default.
public static let libraryEnabled = "punktfunk.libraryEnabled" public static let libraryEnabled = "punktfunk.libraryEnabled"
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default. /// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
@@ -339,6 +339,9 @@ public final class StreamViewController: UIViewController {
setCaptured(false) setCaptured(false)
inputCapture?.stop() inputCapture?.stop()
inputCapture = nil inputCapture = nil
// Release anything the touch-driven mouse still holds (a mid-drag session end) while
// onTouchEvent can still deliver the button-up.
streamView.resetTouchInput()
streamView.onTouchEvent = nil streamView.onTouchEvent = nil
streamView.onPointerMoveAbs = nil streamView.onPointerMoveAbs = nil
streamView.onPointerButton = nil streamView.onPointerButton = nil
@@ -454,7 +457,8 @@ final class StreamLayerUIView: UIView {
/// Reads the LIVE negotiated mode in pixels (the touch/pointer coordinate space). /// Reads the LIVE negotiated mode in pixels (the touch/pointer coordinate space).
var currentHostMode: (() -> CGSize)? var currentHostMode: (() -> CGSize)?
/// Direct fingers / Pencil wire touch events. /// Direct fingers / Pencil wire events: real touches in passthrough mode, or the
/// touch-driven mouse events (`TouchMouse`) in the trackpad/pointer modes.
var onTouchEvent: ((PunktfunkInputEvent) -> Void)? var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
/// Indirect pointer (mouse/trackpad with no lock) absolute cursor moves. /// Indirect pointer (mouse/trackpad with no lock) absolute cursor moves.
var onPointerMoveAbs: ((HostPoint) -> Void)? var onPointerMoveAbs: ((HostPoint) -> Void)?
@@ -468,6 +472,22 @@ final class StreamLayerUIView: UIView {
/// GameStream button held per active indirect-pointer touch (one click/drag session); /// GameStream button held per active indirect-pointer touch (one click/drag session);
/// released when that touch ends. /// released when that touch ends.
private var pointerButtons: [ObjectIdentifier: UInt32] = [:] private var pointerButtons: [ObjectIdentifier: UInt32] = [:]
/// Touch-driven mouse for the trackpad/pointer `TouchInputMode`s (see TouchMouse.swift).
private lazy var touchMouse: TouchMouse = {
let mouse = TouchMouse()
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
return mouse
}()
/// The finger route latched at gesture start a Settings change mid-gesture applies to
/// the NEXT touch, so one gesture never splits across input models.
private var fingerRoute: TouchInputMode?
/// Release anything the touch-driven mouse holds and forget gesture state session stop.
func resetTouchInput() {
touchMouse.reset()
fingerRoute = nil
}
#endif #endif
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -504,10 +524,10 @@ final class StreamLayerUIView: UIView {
route(touches, event: event, kind: .up) route(touches, event: event, kind: .up)
} }
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
route(touches, event: event, kind: .up) route(touches, event: event, kind: .cancel)
} }
private enum TouchKind { case down, move, up } private enum TouchKind { case down, move, up, cancel }
/// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives /// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives
/// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host /// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host
@@ -521,7 +541,28 @@ final class StreamLayerUIView: UIView {
fingers.insert(touch) fingers.insert(touch)
} }
} }
if !fingers.isEmpty { forwardTouches(fingers, kind: kind) } if !fingers.isEmpty { forwardFingers(fingers, kind: kind) }
}
/// Route direct fingers by the touch-input model, latched for the whole gesture:
/// passthrough real wire touches; trackpad/pointer the TouchMouse gesture engine.
private func forwardFingers(_ touches: Set<UITouch>, kind: TouchKind) {
let mode = fingerRoute ?? TouchInputMode.current
fingerRoute = mode
switch mode {
case .touch:
// A cancellation lifts the wire touch like a normal up the host just sees the
// contact end.
forwardTouches(touches, kind: kind == .cancel ? .up : kind)
case .trackpad, .pointer:
switch kind {
case .down: touchMouse.began(touches, in: self, trackpad: mode == .trackpad)
case .move: touchMouse.moved(touches, in: self)
case .up: touchMouse.ended(touches, in: self)
case .cancel: touchMouse.cancelled(touches)
}
}
if touchIDs.isEmpty, touchMouse.isIdle { fingerRoute = nil }
} }
/// An indirect-pointer touch is a button-held click/drag session: forward its position as /// An indirect-pointer touch is a button-held click/drag session: forward its position as
@@ -537,7 +578,7 @@ final class StreamLayerUIView: UIView {
onPointerButton?(button, true) onPointerButton?(button, true)
case .move: case .move:
if let host { onPointerMoveAbs?(host) } if let host { onPointerMoveAbs?(host) }
case .up: case .up, .cancel:
if let host { onPointerMoveAbs?(host) } if let host { onPointerMoveAbs?(host) }
if let button = pointerButtons.removeValue(forKey: key) { if let button = pointerButtons.removeValue(forKey: key) {
onPointerButton?(button, false) onPointerButton?(button, false)
@@ -554,7 +595,7 @@ final class StreamLayerUIView: UIView {
case .down: case .down:
id = nextFreeID() id = nextFreeID()
touchIDs[key] = id touchIDs[key] = id
case .move, .up: case .move, .up, .cancel:
guard let known = touchIDs[key] else { continue } guard let known = touchIDs[key] else { continue }
id = known id = known
} }
@@ -0,0 +1,97 @@
import XCTest
@testable import PunktfunkKit
/// Pins the rumble renderer's pure scheduling/mapping decisions and the relations between its
/// tuning constants that the design depends on (see `RumbleRenderer`'s invariants). No
/// CHHapticEngine or physical pad involved.
final class RumbleTuningTests: XCTestCase {
func testAmplitudeMapsWireRangeToUnitInterval() {
XCTAssertEqual(RumbleTuning.amplitude(0), 0)
XCTAssertEqual(RumbleTuning.amplitude(0xFFFF), 1)
XCTAssertEqual(RumbleTuning.amplitude(0x8000), Float(0x8000) / 65535, accuracy: 1e-6)
// Monotonic a stronger wire value can never render weaker.
XCTAssertLessThan(RumbleTuning.amplitude(0x1000), RumbleTuning.amplitude(0x2000))
}
func testHidByteMapsWireRangeToPadRange() {
XCTAssertEqual(RumbleTuning.hidByte(0), 0)
XCTAssertEqual(RumbleTuning.hidByte(0xFFFF), 255)
XCTAssertEqual(RumbleTuning.hidByte(0x8000), 0x80)
}
func testCombinedActuatorRendersStrongerMotor() {
XCTAssertEqual(RumbleTuning.combined(low: 0x4000, high: 0x8000), 0x8000)
XCTAssertEqual(RumbleTuning.combined(low: 0x8000, high: 0x4000), 0x8000)
XCTAssertEqual(RumbleTuning.combined(low: 0, high: 0), 0)
}
func testLevelDedupeEpsilon() {
// An identical host refresh (and LSB jitter) is the same level no player rebuild.
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5))
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon))
// A real level change is not.
XCTAssertFalse(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon * 3))
XCTAssertFalse(RumbleTuning.sameLevel(0, 1))
}
func testRearmDecision() {
let ends: TimeInterval = 100
XCTAssertFalse(
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom - 0.1))
XCTAssertTrue(
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom + 0.1))
// Even a segment already past its end re-arms (the gap already happened; recover).
XCTAssertTrue(RumbleTuning.shouldRearm(endsAt: ends, now: ends + 1))
}
func testHandoffStartsAtSegmentEndNeverInThePast() {
// Successor starts exactly at the predecessor's end...
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 99.5), 100)
// ...unless that instant already passed then start immediately, not in the past.
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
}
func testPolicies() {
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
// holds a level indefinitely.
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
}
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
/// storm, an audible target left to the ticker (watchdog path), then `stop()` which runs
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
func testRendererSurvivesCallStormAndTeardownWithoutController() {
let renderer = RumbleRenderer(policy: .session)
renderer.retarget(nil)
for i in 0..<500 {
renderer.apply(
low: i % 2 == 0 ? 0x8000 : 0, high: UInt16(truncatingIfNeeded: i &* 37))
}
// Leave a nonzero target long enough for the ticker to spin a few times.
renderer.apply(low: 0x4000, high: 0x4000)
Thread.sleep(forTimeInterval: 0.2)
renderer.stop()
}
func testTuningRelationsTheDesignDependsOn() {
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
// but trip well before a stuck rumble reads as "still going".
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
// segment boundary and gap.
XCTAssertGreaterThanOrEqual(
RumbleTuning.rearmHeadroom, 4 * RumbleTuning.tickSeconds)
// The headroom must fit inside a segment, or re-arm would trigger instantly forever.
XCTAssertLessThan(RumbleTuning.rearmHeadroom, RumbleTuning.segmentSeconds)
// The rebake throttle must be far under the host refresh period, or refreshed level
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
// watchdog, or those deadlines could be overshot by a full period.
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
}
}
@@ -0,0 +1,42 @@
#if os(iOS)
import XCTest
@testable import PunktfunkKit
/// Pins the touch-mouse tuning contract (ported 1:1 from the Android client's TouchInput.kt
/// so the two touch clients feel identical) and the mode parsing. The gesture state machine
/// itself needs UITouch instances and is validated on-glass.
final class TouchMouseTests: XCTestCase {
func testModeParsingDefaultsToTrackpad() {
XCTAssertEqual(TouchInputMode(rawValue: "trackpad"), .trackpad)
XCTAssertEqual(TouchInputMode(rawValue: "pointer"), .pointer)
XCTAssertEqual(TouchInputMode(rawValue: "touch"), .touch)
// Unknown/unset values must fall back to trackpad never crash or go touch-silent.
XCTAssertNil(TouchInputMode(rawValue: "bogus"))
}
func testAccelerationCurve() {
// At or below the speed floor: no acceleration slow drags stay precise.
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 0), 1)
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: TouchMouse.Tuning.accelSpeedFloor), 1)
// Above the floor the gain ramps...
let mid = TouchMouse.Tuning.accel(forSpeed: 1.0)
XCTAssertGreaterThan(mid, 1)
XCTAssertLessThan(mid, TouchMouse.Tuning.accelMax)
// ...and a flick is capped so it can't fling the cursor uncontrollably.
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 100), TouchMouse.Tuning.accelMax)
// Monotonic in between.
XCTAssertLessThanOrEqual(
TouchMouse.Tuning.accel(forSpeed: 0.5), TouchMouse.Tuning.accel(forSpeed: 1.5))
}
func testTuningRelations() {
// The tap-drag window must be long enough to hit but short enough not to turn every
// second tap into a drag.
XCTAssertGreaterThan(TouchMouse.Tuning.tapDragWindow, 0.1)
XCTAssertLessThan(TouchMouse.Tuning.tapDragWindow, 0.5)
// A wheel notch per ~10 pt of two-finger pan (the indirect-trackpad path's feel).
XCTAssertGreaterThan(TouchMouse.Tuning.scrollNotchPt, 0)
}
}
#endif
+20 -16
View File
@@ -1,7 +1,7 @@
# punktfunk — Steam Deck plugin (Decky) # Punktfunk — Steam Deck plugin (Decky)
Stream to your **Steam Deck** without ever leaving Gaming Mode. This Stream to your **Steam Deck** without ever leaving Gaming Mode. This
**[Decky Loader](https://decky.xyz/)** plugin adds a **punktfunk** panel to the Quick Access Menu **[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch (the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable. a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
@@ -12,12 +12,16 @@ the panel looks and feels native to Gaming Mode.
## What it does ## What it does
1. **Discover** — browses the LAN over mDNS for punktfunk hosts, in both the QAM panel and a 1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
fullscreen page. fullscreen page; each host row opens a details view (address, pairing policy, certificate
fingerprint to cross-check against the host's log).
2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing 2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
ceremony headlessly, then remembers the host so future streams connect silently. ceremony headlessly, then remembers the host so future streams connect silently.
3. **Stream** — launches fullscreen via a hidden Steam shortcut so gamescope focuses it. 3. **Stream** — launches fullscreen via a hidden Steam shortcut so gamescope focuses it.
4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's config. 4. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
to the client's config.
5. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
a force-stop for a wedged stream client.
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
"game" from the Steam overlay — either returns you to Gaming Mode. "game" from the Steam overlay — either returns you to Gaming Mode.
@@ -37,8 +41,10 @@ https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.z
``` ```
(or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without (or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without
the Decky store — when a newer build exists, an **Update to vX** button appears and drives Decky the Decky store — when a newer build exists, an **Update** button appears and drives Decky
Loader's own (SHA-256-verified) install. Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
before the actual download proceeds.
## Build & sideload (development) ## Build & sideload (development)
@@ -58,20 +64,18 @@ restart is required for an out-of-band install to appear.
| File | Role | | File | Role |
| --- | --- | | --- | --- |
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad, settings). | | `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. | | `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update hooks + actions; the render error boundary. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
| `src/backend.ts` | Typed `callable` bridges to `main.py`. | | `src/backend.ts` | Typed `callable` bridges to `main.py`. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). | | `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). |
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update`. | | `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. | | `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
The client binary is resolved `PATH``/usr/bin``/usr/local/bin``~/.local/bin` → a
`flatpak run io.unom.Punktfunk` fallback, so the flatpak install always works.
## Limitations / next steps ## Limitations / next steps
- **Needs on-Deck validation in Gaming Mode** — the Steam-shortcut launch and headless pairing follow
MoonDeck's proven pattern but are verified only at build time here.
- No manual "add host by IP" entry yet (discovery is mDNS-only). - No manual "add host by IP" entry yet (discovery is mDNS-only).
- No in-stream overlay inside the plugin — the client owns the session once launched. - No in-stream overlay inside the plugin — the client owns the session once launched.
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm - Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
+5
View File
@@ -18,6 +18,11 @@
# #
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and # Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope. # WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
#
# NO EXEC BIT REQUIRED: the Steam shortcut's exe is `/bin/sh` and this script rides behind
# `%command%` as an argument (see src/steam.ts). Decky extracts plugin zips without preserving
# permission bits and ~/homebrew/plugins is root-owned (the unprivileged plugin backend can't
# chmod), so the launch path must never depend on +x. Keep this script POSIX-sh clean.
set -u set -u
APPID="${PF_APPID:-io.unom.Punktfunk}" APPID="${PF_APPID:-io.unom.Punktfunk}"
+60 -9
View File
@@ -29,7 +29,6 @@ import json
import os import os
import shutil import shutil
import ssl import ssl
import stat
import time import time
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -125,13 +124,68 @@ def _semver_tuple(v: str) -> tuple[int, int, int]:
return (parts[0], parts[1], parts[2]) return (parts[0], parts[1], parts[2])
# Decky Loader ships its own embedded (PyInstaller) Python whose compiled-in OpenSSL default
# verify paths don't exist on SteamOS — ``ssl.create_default_context()`` then trusts NOTHING
# and every HTTPS fetch dies with CERTIFICATE_VERIFY_FAILED (seen live on the Deck). Fix: find
# a real CA bundle on disk and load it explicitly. Verification is NEVER disabled — if no
# bundle exists the fetch just fails, and check_update() is non-fatal by design.
_CA_BUNDLES = (
"/etc/ssl/certs/ca-certificates.crt", # SteamOS / Arch / Debian / Ubuntu
"/etc/ssl/cert.pem", # Arch/openssl compat symlink
"/etc/pki/tls/certs/ca-bundle.crt", # Fedora / Bazzite
"/etc/ssl/ca-bundle.pem", # openSUSE
)
_ssl_context_cache: ssl.SSLContext | None = None
def _build_ssl_context() -> ssl.SSLContext:
"""A verifying SSLContext that actually has CA roots under Decky's embedded Python."""
ctx = ssl.create_default_context() # honors SSL_CERT_FILE / SSL_CERT_DIR when set
if ctx.cert_store_stats().get("x509_ca", 0):
return ctx # the interpreter found its own roots (e.g. a system python)
dvp = ssl.get_default_verify_paths()
candidates: list[str | None] = [dvp.cafile, dvp.openssl_cafile, *_CA_BUNDLES]
try: # not shipped by Decky's runtime, but honor it when importable
import certifi
candidates.append(certifi.where())
except ImportError:
pass
tried: set[str] = set()
for cafile in candidates:
if not cafile or cafile in tried or not Path(cafile).is_file():
continue
tried.add(cafile)
try:
ctx.load_verify_locations(cafile=cafile)
except (ssl.SSLError, OSError):
continue
if ctx.cert_store_stats().get("x509_ca", 0):
decky.logger.info("TLS roots loaded from %s", cafile)
return ctx
decky.logger.warning(
"no CA bundle found — HTTPS update checks will fail certificate verification"
)
return ctx
def _ssl_context() -> ssl.SSLContext:
"""The (cached) context for registry fetches; building it scans disk, so do it once."""
global _ssl_context_cache
if _ssl_context_cache is None:
_ssl_context_cache = _build_ssl_context()
return _ssl_context_cache
def _fetch_json(url: str, timeout: float = 8.0) -> dict: def _fetch_json(url: str, timeout: float = 8.0) -> dict:
"""Blocking HTTPS GET of a small JSON document (run in an executor).""" """Blocking HTTPS GET of a small JSON document (run in an executor)."""
req = urllib.request.Request( req = urllib.request.Request(
url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"} url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"}
) )
ctx = ssl.create_default_context() with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return json.loads(resp.read().decode("utf-8", errors="replace")) return json.loads(resp.read().decode("utf-8", errors="replace"))
@@ -319,13 +373,10 @@ class Plugin:
async def runner_info(self) -> dict: async def runner_info(self) -> dict:
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam """The wrapper-script path + flatpak app id the frontend needs to create the Steam
shortcut. Also (re)asserts the script's exec bit — packaging can drop it.""" shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir
means this unprivileged backend couldn't chmod it back on anyway."""
path = _runner_path() path = _runner_path()
try:
st = os.stat(path)
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
except OSError:
decky.logger.warning("could not chmod runner %s", path)
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()} return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
async def get_settings(self) -> dict: async def get_settings(self) -> dict:
+3 -2
View File
@@ -1,14 +1,15 @@
{ {
"name": "punktfunk-decky", "name": "punktfunk-decky",
"version": "0.0.1", "version": "0.0.1",
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the punktfunk streaming client.", "description": "SteamOS / Steam Deck Gaming-Mode launcher for the Punktfunk streaming client.",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "rollup -c", "build": "rollup -c",
"watch": "rollup -c -w", "watch": "rollup -c -w",
"typecheck": "tsc --noEmit --skipLibCheck",
"package": "pnpm build && bash scripts/package.sh", "package": "pnpm build && bash scripts/package.sh",
"deploy": "bash scripts/deploy.sh", "deploy": "bash scripts/deploy.sh",
"test": "echo \"Error: no test specified\" && exit 1" "test": "pnpm typecheck"
}, },
"keywords": [ "keywords": [
"decky", "decky",
+1 -1
View File
@@ -5,7 +5,7 @@
"api_version": 1, "api_version": 1,
"publish": { "publish": {
"tags": ["streaming", "game-streaming", "remote-play"], "tags": ["streaming", "game-streaming", "remote-play"],
"description": "Launch the punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS and connect to one.", "description": "Launch the Punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS, pair with a PIN, and stream.",
"image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader" "image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
} }
} }
+6 -2
View File
@@ -6,7 +6,8 @@ export interface Host {
host: string; host: string;
port: number; port: number;
pair: string; // "required" | "optional" — the HOST's policy pair: string; // "required" | "optional" — the HOST's policy
fp: string; fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
proto: string; // advertised protocol, e.g. "punktfunk/1"
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint) paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
} }
@@ -22,12 +23,15 @@ export interface RunnerInfo {
exists: boolean; exists: boolean;
} }
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
// keys (codec, decoder, … set from the desktop client's own UI) — they round-trip untouched
// because get_settings returns the whole parsed file and patches are object spreads.
export interface StreamSettings { export interface StreamSettings {
width: number; // 0 = native width: number; // 0 = native
height: number; // 0 = native height: number; // 0 = native
refresh_hz: number; // 0 = native refresh_hz: number; // 0 = native
bitrate_kbps: number; // 0 = host default bitrate_kbps: number; // 0 = host default
gamepad: string; // "auto" | "xbox360" | "dualsense" gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
inhibit_shortcuts: boolean; inhibit_shortcuts: boolean;
mic_enabled: boolean; mic_enabled: boolean;
+51
View File
@@ -0,0 +1,51 @@
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
// "Something went wrong while displaying this content" for the entire tab when one plugin
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
// (possibly broken) Steam-internal component — it is guaranteed to render.
import { Component, ErrorInfo, ReactNode } from "react";
export class PluginErrorBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Surface it for diagnosis, but never rethrow — containment is the whole point.
// eslint-disable-next-line no-console
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div style={{ padding: "1em", lineHeight: 1.45 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
Punktfunk couldnt draw this view
</div>
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
The plugin hit a display error your Steam Deck is fine. Reload Punktfunk from
Decky&apos;s plugin list, or update the plugin.
</div>
<div
style={{
opacity: 0.55,
fontFamily: "monospace",
fontSize: "0.8em",
wordBreak: "break-word",
}}
>
{String(error?.message ?? error)}
</div>
</div>
);
}
}
+139
View File
@@ -0,0 +1,139 @@
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useState } from "react";
import { checkUpdate, discover, Host, UpdateInfo } from "./backend";
import { launchStream } from "./steam";
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
// is root-owned, so our unprivileged backend can't swap its own files.
declare global {
interface Window {
DeckyBackend?: {
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
};
}
}
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
const INSTALL_TYPE_UPDATE = 2;
// ----------------------------------------------------------------------------------------
// Discovery — mDNS scan state shared by the QAM panel and the full page.
// ----------------------------------------------------------------------------------------
export function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [scanning, setScanning] = useState(false);
const refresh = useCallback(async () => {
setScanning(true);
try {
setHosts(await discover());
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
} finally {
setScanning(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
// ----------------------------------------------------------------------------------------
// Self-update — checks our registry on mount (the backend caches for 30 min + is non-fatal
// offline); `check(true)` bypasses the cache for the explicit "Check for updates" button.
// ----------------------------------------------------------------------------------------
export function useUpdate() {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [checking, setChecking] = useState(false);
const check = useCallback(async (force: boolean): Promise<UpdateInfo | null> => {
setChecking(true);
try {
const res = await checkUpdate(force);
setInfo(res);
return res;
} catch {
return null;
} finally {
setChecking(false);
}
}, []);
useEffect(() => {
void check(false);
}, [check]);
return { info, checking, check };
}
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
export async function checkForUpdatesNow(
check: (force: boolean) => Promise<UpdateInfo | null>,
): Promise<void> {
const res = await check(true);
let body: string;
if (!res || res.error === "fetch-failed") {
body = "Couldnt reach the update server — are you online?";
} else if (res.error === "update-channel-unknown") {
body = "Development build — update checks are disabled.";
} else if (res.update_available) {
body = `Update available: v${res.current} → v${res.latest}.`;
} else {
body = `Youre up to date (v${res.current}).`;
}
toaster.toast({ title: "Punktfunk", body });
}
export async function applyUpdate(info: UpdateInfo): Promise<void> {
try {
const backend = window.DeckyBackend;
if (backend?.callable) {
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
void backend.callable("utilities/install_plugin")(
info.artifact,
"punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
);
toaster.toast({
title: "Punktfunk",
// Decky's installer also phones the plugin store first, which can hang on some
// networks before the actual install proceeds — set expectations.
body: `Updating to v${info.latest} — confirm Deckys prompt. This can take a couple of minutes.`,
});
return;
}
} catch {
// fall through to the manual path
}
toaster.toast({
title: "Punktfunk",
body: "Update from Decky → Developer → Install Plugin from URL.",
});
}
// ----------------------------------------------------------------------------------------
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
// ----------------------------------------------------------------------------------------
export async function startStream(h: Host): Promise<void> {
try {
await launchStream(h.host, h.port);
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting stream — ${h.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
+56 -558
View File
@@ -1,591 +1,65 @@
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
import { import {
ButtonItem, ButtonItem,
Dropdown,
Field, Field,
Focusable,
DialogButton,
ModalRoot,
Navigation, Navigation,
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
SliderField,
Spinner, Spinner,
Tabs,
ToggleField,
showModal, showModal,
staticClasses, staticClasses,
} from "@decky/ui"; } from "@decky/ui";
import { definePlugin, routerHook, toaster } from "@decky/api"; import { definePlugin, routerHook } from "@decky/api";
import { import { FC } from "react";
Component, import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa";
CSSProperties, import { PluginErrorBoundary } from "./boundary";
ErrorInfo, import { applyUpdate, checkForUpdatesNow, startStream, useHosts, useUpdate } from "./hooks";
FC, import { PunktfunkRoute, ROUTE } from "./page";
ReactNode, import { PairModal } from "./pair";
useCallback,
useEffect,
useState,
} from "react";
import {
FaTv,
FaSyncAlt,
FaLock,
FaLockOpen,
FaPlay,
FaArrowLeft,
FaDownload,
} from "react-icons/fa";
import {
discover,
getSettings,
pair,
setSettings,
checkUpdate,
Host,
StreamSettings,
UpdateInfo,
} from "./backend";
import { launchStream } from "./steam";
const ROUTE = "/punktfunk";
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
// is root-owned, so our unprivileged backend can't swap its own files.
declare global {
interface Window {
DeckyBackend?: {
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
};
}
}
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
const INSTALL_TYPE_UPDATE = 2;
// ----------------------------------------------------------------------------------------
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
// "Something went wrong while displaying this content" for the entire tab when one plugin
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
// (possibly broken) Steam-internal component — it is guaranteed to render.
// ----------------------------------------------------------------------------------------
class PluginErrorBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Surface it for diagnosis, but never rethrow — containment is the whole point.
// eslint-disable-next-line no-console
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div style={{ padding: "1em", lineHeight: 1.45 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
punktfunk couldnt draw this view
</div>
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
The plugin hit a display error your Steam Deck is fine. Reload punktfunk from
Decky&apos;s plugin list, or update the plugin.
</div>
<div
style={{
opacity: 0.55,
fontFamily: "monospace",
fontSize: "0.8em",
wordBreak: "break-word",
}}
>
{String(error?.message ?? error)}
</div>
</div>
);
}
}
// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline).
function useUpdate() {
const [info, setInfo] = useState<UpdateInfo | null>(null);
useEffect(() => {
void checkUpdate(false)
.then(setInfo)
.catch(() => {});
}, []);
return info;
}
async function applyUpdate(info: UpdateInfo) {
try {
const backend = window.DeckyBackend;
if (backend?.callable) {
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
void backend.callable("utilities/install_plugin")(
info.artifact,
"punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
);
toaster.toast({
title: "punktfunk",
body: `Updating to v${info.latest}… confirm the Decky prompt.`,
});
return;
}
} catch {
// fall through to the manual path
}
toaster.toast({
title: "punktfunk",
body: "Update from Decky → Developer → Install Plugin from URL.",
});
}
// ----------------------------------------------------------------------------------------
// Discovery hook — shared by the QAM panel and the full page.
// ----------------------------------------------------------------------------------------
function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [scanning, setScanning] = useState(false);
const refresh = useCallback(async () => {
setScanning(true);
try {
setHosts(await discover());
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
} finally {
setScanning(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
async function startStream(h: Host) {
try {
await launchStream(h.host, h.port);
Navigation.CloseSideMenus();
toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` });
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` });
}
}
// ----------------------------------------------------------------------------------------
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
// The host displays the PIN after the operator arms pairing; the user enters it here.
// ----------------------------------------------------------------------------------------
const PairModal: FC<{
host: Host;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
const back = () => setPin((p) => p.slice(0, -1));
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setPin("");
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Pair with {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
</div>
<div
style={{
fontSize: "2.2em",
letterSpacing: "0.4em",
textAlign: "center",
fontFamily: "monospace",
minHeight: "1.4em",
marginBottom: "0.6em",
}}
>
{pin.padEnd(4, "•")}
</div>
{error && (
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
{error}
</div>
)}
<Focusable
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "0.5em",
}}
>
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
{d}
</DialogButton>
))}
<DialogButton disabled={busy} onClick={back}>
</DialogButton>
<DialogButton disabled={busy} onClick={() => press("0")}>
0
</DialogButton>
<DialogButton
disabled={busy || pin.length !== 4}
onClick={submit}
>
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON.
// ----------------------------------------------------------------------------------------
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
];
const REFRESH = [0, 30, 60, 90, 120];
const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
dualsense: "DualSense",
steamdeck: "Steam Deck",
};
const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
useEffect(() => {
void getSettings().then(setS);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field label="Gamepad type" childrenContainerWidth="max">
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</Field>
{s.gamepad === "steamdeck" && (
<Field
label="⚠ Disable Steam Input"
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<ToggleField
label="Stream microphone"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
);
};
// ----------------------------------------------------------------------------------------
// One host row on the full page.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{ host: Host }> = ({ host }) => {
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
// pair again — show it as trusted and go straight to Stream.
const needsPair = host.pair === "required" && !host.paired;
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{needsPair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={`${host.host}:${host.port}${
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em" }}>
{needsPair && (
<DialogButton
style={{ minWidth: "5em" }}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => {}} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</Focusable>
</Field>
);
};
// ----------------------------------------------------------------------------------------
// The fullscreen page (registered as the /punktfunk route) — a tabbed Hosts / Settings view.
// ----------------------------------------------------------------------------------------
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
const SAFE_BOTTOM = "80px";
// Each tab is its own scroll area so long content is always reachable above the footer.
const tabScroll: CSSProperties = {
height: "100%",
overflowY: "auto",
padding: "0.5em 2.5em",
paddingBottom: SAFE_BOTTOM,
boxSizing: "border-box",
};
const HostsTab: FC<{
hosts: Host[];
scanning: boolean;
refresh: () => void;
}> = ({ hosts, scanning, refresh }) => (
<div style={tabScroll}>
<Field
label="Discover"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
description="No punktfunk hosts found. Make sure a host is running on the same network."
>
No hosts found
</Field>
)}
{hosts.map((h) => (
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
))}
</div>
);
const SettingsTab: FC = () => (
<div style={tabScroll}>
<SettingsSection />
</div>
);
const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts();
const update = useUpdate();
const [tab, setTab] = useState("hosts");
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton
style={{ width: "3em", minWidth: "3em", padding: 0 }}
onClick={() => Navigation.NavigateBack()}
>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
punktfunk
</div>
{update?.update_available && (
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update v{update.latest}
</DialogButton>
)}
</Focusable>
<div style={{ flex: 1, minHeight: 0 }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[
{
id: "hosts",
title: "Hosts",
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
]}
/>
</div>
</div>
);
};
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts. // QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
const QamPanel: FC = () => { const QamPanel: FC = () => {
const { hosts, scanning, refresh } = useHosts(); const { hosts, scanning, refresh } = useHosts();
const update = useUpdate(); const { info: update, checking, check } = useUpdate();
return ( return (
<> <>
{update?.update_available && ( {update?.update_available && (
<PanelSection title="Update"> <PanelSection title="Update available">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem <ButtonItem
layout="below" layout="below"
onClick={() => applyUpdate(update)} onClick={() => applyUpdate(update)}
label={`v${update.current} → v${update.latest}`} label={`v${update.current} → v${update.latest}`}
description="Installing can take a couple of minutes"
> >
<FaDownload style={{ marginRight: "0.5em" }} /> <FaDownload style={{ marginRight: "0.5em" }} />
Update punktfunk Update Punktfunk
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
</PanelSection> </PanelSection>
)} )}
<PanelSection title="punktfunk"> <PanelSection title="Punktfunk">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem <ButtonItem
layout="below" layout="below"
description="Host details, stream settings, and help"
onClick={() => { onClick={() => {
Navigation.Navigate(ROUTE); Navigation.Navigate(ROUTE);
Navigation.CloseSideMenus(); Navigation.CloseSideMenus();
}} }}
> >
<FaTv style={{ marginRight: "0.5em" }} /> <FaTv style={{ marginRight: "0.5em" }} />
Open punktfunk Open Punktfunk
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
</PanelSection>
<PanelSection title="Hosts">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={scanning}> <ButtonItem layout="below" onClick={refresh} disabled={scanning}>
{scanning ? ( {scanning ? (
@@ -593,15 +67,21 @@ const QamPanel: FC = () => {
) : ( ) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} /> <FaSyncAlt style={{ marginRight: "0.5em" }} />
)} )}
{scanning ? "Scanning…" : "Refresh hosts"} {scanning ? "Scanning…" : "Refresh"}
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
</PanelSection> {hosts.length === 0 && scanning && (
<PanelSectionRow>
<PanelSection title="Hosts"> <Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{hosts.length === 0 && !scanning && ( {hosts.length === 0 && !scanning && (
<PanelSectionRow> <PanelSectionRow>
<Field focusable={false}>No hosts found.</Field> <Field
focusable={false}
label="No hosts found"
description="Start a Punktfunk host on this network, then refresh."
/>
</PanelSectionRow> </PanelSectionRow>
)} )}
{hosts.map((h) => { {hosts.map((h) => {
@@ -629,24 +109,42 @@ const QamPanel: FC = () => {
); );
})} })}
</PanelSection> </PanelSection>
<PanelSection title="About">
<PanelSectionRow>
<Field
focusable={false}
label="Version"
description={
update
? `v${update.current}${update.channel ? ` · ${update.channel}` : " · dev build"}`
: "…"
}
/>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? "Checking…" : "Check for updates"}
</ButtonItem>
</PanelSectionRow>
</PanelSection>
</> </>
); );
}; };
// Full page behind the boundary — registered as the /punktfunk route.
const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
export default definePlugin(() => { export default definePlugin(() => {
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true }); routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
return { return {
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
name: "punktfunk", name: "punktfunk",
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw // `staticClasses?.Title` is guarded so a future client that drops the export can't throw
// at plugin-load time (an error boundary only catches render-time, not load-time, errors). // at plugin-load time (an error boundary only catches render-time, not load-time, errors).
titleView: <div className={staticClasses?.Title}>punktfunk</div>, titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
content: ( content: (
<PluginErrorBoundary> <PluginErrorBoundary>
<QamPanel /> <QamPanel />
+338
View File
@@ -0,0 +1,338 @@
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
import {
DialogButton,
Field,
Focusable,
ModalRoot,
Navigation,
Spinner,
Tabs,
showModal,
staticClasses,
} from "@decky/ui";
import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react";
import {
FaArrowLeft,
FaDownload,
FaExternalLinkAlt,
FaInfoCircle,
FaLock,
FaLockOpen,
FaPlay,
FaSyncAlt,
} from "react-icons/fa";
import { Host, UpdateInfo, killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import {
DOCS_URL,
applyUpdate,
checkForUpdatesNow,
startStream,
useHosts,
useUpdate,
} from "./hooks";
import { PairModal } from "./pair";
import { SettingsSection } from "./settings";
import { stopStream } from "./steam";
export const ROUTE = "/punktfunk";
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
const SAFE_BOTTOM = "80px";
// Each tab is its own scroll area so long content is always reachable above the footer.
const tabScroll: CSSProperties = {
height: "100%",
overflowY: "auto",
padding: "0.5em 2.5em",
paddingBottom: SAFE_BOTTOM,
boxSizing: "border-box",
};
// ----------------------------------------------------------------------------------------
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
// against the host's own log / web console before trusting it.
// ----------------------------------------------------------------------------------------
const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
host,
closeModal,
}) => {
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised";
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name}
</div>
<Field focusable={false} label="Address">
{host.host}:{host.port}
</Field>
<Field focusable={false} label="Protocol">
{host.proto || "unknown"}
</Field>
<Field focusable={false} label="Pairing policy">
{host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"}
</Field>
<Field focusable={false} label="This Deck">
{host.paired ? "Paired" : "Not paired yet"}
</Field>
<Field
focusable={false}
label="Certificate fingerprint (SHA-256)"
description={
<span
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
>
{fp}
</span>
}
/>
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// One host row: status icon + address, details / pair / stream actions.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) => {
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
// pair again — show it as trusted and go straight to Stream.
const needsPair = host.pair === "required" && !host.paired;
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{needsPair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={`${host.host}:${host.port}${
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em" }}>
<DialogButton
style={{ width: "3em", minWidth: "3em", padding: 0 }}
onClick={() => showModal(<HostDetailsModal host={host} />)}
>
<FaInfoCircle />
</DialogButton>
{needsPair && (
<DialogButton
style={{ minWidth: "5em" }}
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
>
Pair
</DialogButton>
)}
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</Focusable>
</Field>
);
};
const HostsTab: FC<{
hosts: Host[];
scanning: boolean;
refresh: () => void;
}> = ({ hosts, scanning, refresh }) => (
<div style={tabScroll}>
<Field
label="Discover"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
label="No hosts found"
description="Start a Punktfunk host on the same network, then refresh. The setup guide (About tab) covers installing a host."
/>
)}
{hosts.map((h) => (
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} onPaired={refresh} />
))}
</div>
);
const SettingsTab: FC = () => (
<div style={tabScroll}>
<SettingsSection />
</div>
);
// ----------------------------------------------------------------------------------------
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop.
// ----------------------------------------------------------------------------------------
async function forceStopStream(): Promise<void> {
stopStream(); // ask Steam to end the "game" first (clean path)
const res = await killStream(); // then the flatpak-level hammer for a wedged client
toaster.toast({
title: "Punktfunk",
body: res.ok ? "Stream client stopped." : "Couldnt stop the stream client.",
});
}
const AboutTab: FC<{
update: UpdateInfo | null;
checking: boolean;
check: (force: boolean) => Promise<UpdateInfo | null>;
}> = ({ update, checking, check }) => (
<div style={tabScroll}>
<Field
label="Version"
description={
update
? `v${update.current}${
update.channel ? ` · ${update.channel} channel` : " · development build"
}`
: "…"
}
childrenContainerWidth="max"
>
<DialogButton
style={{ minWidth: "11em" }}
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
</Field>
{update?.update_available && (
<Field
label={`Update available — v${update.latest}`}
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
childrenContainerWidth="max"
>
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</Field>
)}
<Field
label="Setup guide"
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max"
>
<DialogButton
style={{ minWidth: "8em" }}
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
>
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</Field>
<Field
focusable={false}
label="Leaving a stream"
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
/>
<Field
label="Stream stuck?"
description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max"
>
<DialogButton style={{ minWidth: "8em" }} onClick={() => void forceStopStream()}>
Force-stop
</DialogButton>
</Field>
</div>
);
const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts();
const { info: update, checking, check } = useUpdate();
const [tab, setTab] = useState("hosts");
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton
style={{ width: "3em", minWidth: "3em", padding: 0 }}
onClick={() => Navigation.NavigateBack()}
>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
Punktfunk
</div>
{update?.update_available && (
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update v{update.latest}
</DialogButton>
)}
</Focusable>
<div style={{ flex: 1, minHeight: 0 }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[
{
id: "hosts",
title: "Hosts",
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
{
id: "about",
title: "About",
content: <AboutTab update={update} checking={checking} check={check} />,
},
]}
/>
</div>
</div>
);
};
// Full page behind the boundary — registered as the /punktfunk route.
export const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
+91
View File
@@ -0,0 +1,91 @@
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
// The host displays the PIN after the operator arms pairing; the user enters it here.
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
import { toaster } from "@decky/api";
import { FC, useState } from "react";
import { Host, pair } from "./backend";
export const PairModal: FC<{
host: Host;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
const back = () => setPin((p) => p.slice(0, -1));
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setPin("");
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Pair with {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
</div>
<div
style={{
fontSize: "2.2em",
letterSpacing: "0.4em",
textAlign: "center",
fontFamily: "monospace",
minHeight: "1.4em",
marginBottom: "0.6em",
}}
>
{pin.padEnd(4, "•")}
</div>
{error && (
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
{error}
</div>
)}
<Focusable
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "0.5em",
}}
>
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
{d}
</DialogButton>
))}
<DialogButton disabled={busy} onClick={back}>
</DialogButton>
<DialogButton disabled={busy} onClick={() => press("0")}>
0
</DialogButton>
<DialogButton disabled={busy || pin.length !== 4} onClick={submit}>
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
+127
View File
@@ -0,0 +1,127 @@
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend";
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1280, 800, "1280 × 800 (Deck)"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
];
const REFRESH = [0, 30, 60, 90, 120];
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
xboxone: "Xbox One",
dualsense: "DualSense",
dualshock4: "DualShock 4",
steamdeck: "Steam Deck",
};
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
const COMPOSITOR_LABELS: Record<string, string> = {
auto: "Automatic",
kwin: "KDE Plasma (KWin)",
wlroots: "Sway (wlroots)",
mutter: "GNOME (Mutter)",
gamescope: "gamescope",
};
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
useEffect(() => {
void getSettings().then(setS);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field
label="Gamepad type"
description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</Field>
{s.gamepad === "steamdeck" && (
<Field
label="⚠ Disable Steam Input"
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<Field
label="Host compositor"
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
</Field>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
);
};
+30 -25
View File
@@ -3,9 +3,10 @@
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to // THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see // the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE // gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the // hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script
// per-session host as the shortcut's Steam launch options, and start it with RunGame. The // (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options,
// wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant. // and start it with RunGame. The wrapper then execs
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
import { runnerInfo } from "./backend"; import { runnerInfo } from "./backend";
@@ -49,7 +50,15 @@ function hideShortcut(appId: number): void {
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
} }
const SHORTCUT_NAME = "punktfunk"; // The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
const SHORTCUT_NAME = "Punktfunk";
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
// backend can't chmod it back on. Passing the script as an argument to the always-executable
// shell removes the +x dependency entirely. SteamOS /bin/sh is bash; the wrapper is plain
// POSIX sh regardless.
const SHELL = "/bin/sh";
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the // The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
// standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this. // standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this.
@@ -78,39 +87,34 @@ function recallAppId(): number | null {
} }
/** /**
* Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and * Ensure exactly one hidden "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
* return its appId. Reuses the remembered one when its exe still matches the current runner * appended per-launch via the launch options), and return its appId + the current runner path.
* path (the plugin dir can change across reinstalls). * Reuses the remembered shortcut, re-pointing it each time — the plugin dir can change across
* reinstalls, and pre-0.4 shortcuts pointed at the script directly and relied on its exec bit.
*/ */
async function ensureShortcut(): Promise<number> { async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
const info = await runnerInfo(); const info = await runnerInfo();
if (!info.exists) { if (!info.exists) {
throw new Error(`launch wrapper missing at ${info.runner}`); throw new Error(`launch wrapper missing at ${info.runner}`);
} }
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
const remembered = recallAppId(); const remembered = recallAppId();
if (remembered != null) { if (remembered != null) {
// Re-point the existing shortcut at the current runner path (cheap + idempotent). // Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
SteamClient.Apps.SetShortcutExe(remembered, info.runner); SteamClient.Apps.SetShortcutExe(remembered, SHELL);
SteamClient.Apps.SetShortcutStartDir( SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
remembered, SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
info.runner.replace(/\/[^/]*$/, ""), return { appId: remembered, runner: info.runner };
);
return remembered;
} }
const appId = await SteamClient.Apps.AddShortcut( const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SHORTCUT_NAME,
info.runner,
info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir
"",
);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
// Hide it from the library — it's an implementation detail, launched programmatically. // Hide it from the library — it's an implementation detail, launched programmatically.
// Best-effort + deferred (see hideShortcut); never let it block the launch. // Best-effort + deferred (see hideShortcut); never let it block the launch.
hideShortcut(appId); hideShortcut(appId);
rememberAppId(appId); rememberAppId(appId);
return appId; return { appId, runner: info.runner };
} }
/** /**
@@ -138,13 +142,14 @@ function disableSteamInputForShortcut(appId: number): void {
* shortcut's launch options (so one generic shortcut serves every host), then RunGame. * shortcut's launch options (so one generic shortcut serves every host), then RunGame.
*/ */
export async function launchStream(host: string, port: number): Promise<void> { export async function launchStream(host: string, port: number): Promise<void> {
const appId = await ensureShortcut(); const { appId, runner } = await ensureShortcut();
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user // Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
// disables Steam Input manually — see the Settings instruction). // disables Steam Input manually — see the Settings instruction).
disableSteamInputForShortcut(appId); disableSteamInputForShortcut(appId);
const target = port && port !== 9777 ? `${host}:${port}` : host; const target = port && port !== 9777 ? `${host}:${port}` : host;
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment. // KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`); // script rides behind it as an argument and reads PF_HOST from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command% "${runner}"`);
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
} }
+10
View File
@@ -128,6 +128,16 @@ fn build_ui(gtk_app: &adw::Application) {
hosts: RefCell::new(None), hosts: RefCell::new(None),
}); });
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
// whenever such a pad connects) — without this the pin silently resets to Automatic on
// every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad).
{
let forward = app.settings.borrow().forward_pad.clone();
if !forward.is_empty() {
app.gamepad.set_pinned(Some(forward));
}
}
let hosts_ui = Rc::new(crate::ui_hosts::new( let hosts_ui = Rc::new(crate::ui_hosts::new(
app.settings.clone(), app.settings.clone(),
HostsCallbacks { HostsCallbacks {
+167 -87
View File
@@ -2,12 +2,21 @@
//! `GamepadCapture`/`GamepadFeedback`). //! `GamepadCapture`/`GamepadFeedback`).
//! //!
//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the //! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most //! Settings UI (metadata only — see below), selects the ONE controller forwarded as pad 0
//! recently connected), and — while a session is attached — forwards buttons/axes, //! (the user pin — persisted in Settings by stable `vid:pid:name` key — else the most
//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on //! recently connected real pad; Steam Input's virtual pad is skipped), and — while a
//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet //! session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
//! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the //! samples (0xCC), and renders feedback: rumble, lightbar via SDL, and on a real DualSense
//! wire when the active pad switches or the session detaches, so nothing sticks down. //! the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). Held
//! state is zeroed on the wire when the active pad switches or the session detaches, so
//! nothing sticks down.
//!
//! **Idle means hands off the hardware.** Outside an attached session the worker never
//! opens a device and keeps SDL's Valve HIDAPI drivers disabled ([`set_valve_hidapi`]):
//! the Steam Deck driver clears the built-in controller's "lizard mode" (trackpad-mouse,
//! clicky pads) the moment the device *enumerates* and keeps feeding that watchdog — so an
//! idle host-list window would kill the Deck's system input. The pad list for Settings is
//! built from SDL's ID-based metadata getters, which need no open.
//! //!
//! This thread is also the single consumer of the rumble and HID-output pull planes. //! This thread is also the single consumer of the rumble and HID-output pull planes.
@@ -15,7 +24,6 @@ use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref; use punktfunk_core::config::GamepadPref;
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind}; use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -44,12 +52,18 @@ const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PadInfo { pub struct PadInfo {
pub id: u32,
pub name: String, pub name: String,
/// Stable identity (`vid:pid:name`) for pinning across restarts — SDL instance ids are
/// per-run, so [`Settings::forward_pad`](crate::trust::Settings) persists this instead.
pub key: String,
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a /// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything /// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path. /// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
pub pref: GamepadPref, pub pref: GamepadPref,
/// Steam Input's emulated pad ("Steam Virtual Gamepad", Valve 28de:11ff). It shadows the
/// physical controller and has no sensors/touchpad, so auto-selection skips it while a real
/// pad is connected — otherwise gyro silently dies on Bazzite/Deck game mode.
pub steam_virtual: bool,
} }
impl PadInfo { impl PadInfo {
@@ -71,6 +85,24 @@ impl PadInfo {
} }
} }
/// Enable/disable SDL's Valve HIDAPI drivers at runtime. The Steam Deck driver sends
/// `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` in `InitDevice` — at *enumeration*, before
/// any open — and its `UpdateDevice` keeps feeding the firmware's lizard-mode watchdog
/// (`SDL_hidapi_steamdeck.c`), so a Deck's built-in trackpad-mouse dies for the whole
/// system while the driver merely runs. These drivers therefore run ONLY while a session
/// is attached (input is captured then anyway, and streaming wants the paddles, both
/// trackpads, and gyro first-class). SDL3 applies the hint changes live: disabling detaches
/// the driver and the firmware watchdog restores lizard mode within seconds.
///
/// On a Deck in Game Mode, Steam Input still holds the device — the user must disable
/// Steam Input for this app (see the Decky UX); on a desktop client (or a Deck with Steam
/// Input off) the in-session enable just works.
fn set_valve_hidapi(enabled: bool) {
let v = if enabled { "1" } else { "0" };
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", v);
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create. /// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T; use sdl3::gamepad::GamepadType as T;
@@ -85,14 +117,13 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
enum Ctl { enum Ctl {
Attach(Arc<NativeClient>), Attach(Arc<NativeClient>),
Detach, Detach,
Pin(Option<u32>), Pin(Option<String>),
} }
#[derive(Clone)] #[derive(Clone)]
pub struct GamepadService { pub struct GamepadService {
pads: Arc<Mutex<Vec<PadInfo>>>, pads: Arc<Mutex<Vec<PadInfo>>>,
active: Arc<Mutex<Option<PadInfo>>>, active: Arc<Mutex<Option<PadInfo>>>,
pinned: Arc<Mutex<Option<u32>>>,
ctl: Sender<Ctl>, ctl: Sender<Ctl>,
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave /// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
/// fullscreen + release capture. /// fullscreen + release capture.
@@ -106,15 +137,14 @@ impl GamepadService {
pub fn start() -> GamepadService { pub fn start() -> GamepadService {
let pads = Arc::new(Mutex::new(Vec::new())); let pads = Arc::new(Mutex::new(Vec::new()));
let active = Arc::new(Mutex::new(None)); let active = Arc::new(Mutex::new(None));
let pinned = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel(); let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (escape_tx, escape_rx) = async_channel::unbounded(); let (escape_tx, escape_rx) = async_channel::unbounded();
let (disconnect_tx, disconnect_rx) = async_channel::unbounded(); let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone()); let (p, a) = (pads.clone(), active.clone());
if let Err(e) = std::thread::Builder::new() if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into()) .name("punktfunk-gamepad".into())
.spawn(move || { .spawn(move || {
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) { if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled"); tracing::warn!(error = %e, "gamepad service ended — pads disabled");
} }
}) })
@@ -124,7 +154,6 @@ impl GamepadService {
GamepadService { GamepadService {
pads, pads,
active, active,
pinned,
ctl, ctl,
escape_rx, escape_rx,
disconnect_rx, disconnect_rx,
@@ -151,12 +180,11 @@ impl GamepadService {
self.active.lock().unwrap().clone() self.active.lock().unwrap().clone()
} }
pub fn pinned(&self) -> Option<u32> { /// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
*self.pinned.lock().unwrap() /// The pin persists as `Settings::forward_pad` (the UI's source of truth) and survives
} /// the pad disconnecting: it re-applies the moment a matching controller shows up again.
pub fn set_pinned(&self, key: Option<String>) {
pub fn set_pinned(&self, id: Option<u32>) { let _ = self.ctl.send(Ctl::Pin(key));
let _ = self.ctl.send(Ctl::Pin(id));
} }
pub fn attach(&self, connector: Arc<NativeClient>) { pub fn attach(&self, connector: Arc<NativeClient>) {
@@ -279,11 +307,16 @@ struct Worker<'a> {
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
pads_out: &'a Mutex<Vec<PadInfo>>, pads_out: &'a Mutex<Vec<PadInfo>>,
active_out: &'a Mutex<Option<PadInfo>>, active_out: &'a Mutex<Option<PadInfo>>,
pinned_out: &'a Mutex<Option<u32>>, /// The ONE device held open — the active pad while a session is attached, `None`
opened: HashMap<u32, sdl3::gamepad::Gamepad>, /// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the
/// Connection order; the most recently connected is the auto selection. /// hidraw device away from the system), so idle keeps this empty; see the module doc.
open: Option<(u32, sdl3::gamepad::Gamepad)>,
/// Connected pad ids in connection order (metadata only, no device open); the most
/// recently connected is the auto selection.
order: Vec<u32>, order: Vec<u32>,
pinned: Option<u32>, /// Stable key of the user-pinned controller (persisted in Settings) — matched against
/// connected pads, so it survives restarts and disconnects.
pinned: Option<String>,
attached: Option<Arc<NativeClient>>, attached: Option<Arc<NativeClient>>,
/// Wire state of the active pad — zeroed on the wire at switch/detach. /// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6], last_axis: [i32; 6],
@@ -308,32 +341,95 @@ struct Worker<'a> {
impl Worker<'_> { impl Worker<'_> {
fn active_id(&self) -> Option<u32> { fn active_id(&self) -> Option<u32> {
self.pinned // The pin matches by stable key (most recently connected wins if two identical pads
.filter(|id| self.opened.contains_key(id)) // share one); an unmatched pin falls through to automatic without being cleared.
if let Some(key) = &self.pinned {
if let Some(id) = self
.order
.iter()
.rev()
.copied()
.find(|&id| self.pad_info(id).is_some_and(|p| &p.key == key))
{
return Some(id);
}
}
// Automatic: the most recently connected pad — but never Steam Input's virtual pad
// while a real controller is present (see `PadInfo::steam_virtual`).
self.order
.iter()
.rev()
.copied()
.find(|&id| self.pad_info(id).is_some_and(|p| !p.steam_virtual))
.or_else(|| self.order.last().copied()) .or_else(|| self.order.last().copied())
} }
/// Pad metadata from SDL's ID-based getters — deliberately NO device open (see the
/// module doc; an open would grab the hardware).
fn pad_info(&self, id: u32) -> Option<PadInfo> { fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?; if !self.order.contains(&id) {
let mut pref = pref_for_type( return None;
self.subsystem }
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), let jid = sdl3::sys::joystick::SDL_JoystickID(id);
let mut pref = pref_for_type(self.subsystem.type_for_id(jid));
let (vid, pid) = (
self.subsystem.vendor_for_id(jid).unwrap_or(0),
self.subsystem.product_for_id(jid).unwrap_or(0),
); );
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by // There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual // VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
// hid-steam pad with the back grips + dual trackpads and the right glyph identity. // hid-steam pad with the back grips + dual trackpads and the right glyph identity.
if pad.vendor_id() == Some(0x28DE) if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
{
pref = GamepadPref::SteamDeck; pref = GamepadPref::SteamDeck;
} }
let name = self
.subsystem
.name_for_id(jid)
.unwrap_or_else(|_| "Controller".into());
Some(PadInfo { Some(PadInfo {
id, key: format!("{vid:04x}:{pid:04x}:{name}"),
name: pad.name().unwrap_or_else(|| "Controller".into()), steam_virtual: (vid == 0x28DE && pid == 0x11FF)
|| name.starts_with("Steam Virtual Gamepad"),
name,
pref, pref,
}) })
} }
/// Hold exactly the right device: the active pad while a session is attached, nothing
/// otherwise. The single place that decides to open (= grab) hardware; dropping the
/// old handle closes it (`SDL_CloseGamepad`) — on a Deck the firmware watchdog then
/// restores lizard mode.
fn sync_open(&mut self) {
let want = if self.attached.is_some() {
self.active_id()
} else {
None
};
if self.open.as_ref().map(|(id, _)| *id) == want {
return;
}
self.open = None;
let Some(id) = want else { return };
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => {
self.open = Some((id, pad));
self.set_sensors(true);
}
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
}
}
/// React to anything that may have moved the active-pad selection (hotplug, pin
/// change): flush held wire state if it did, then re-sync the opened device and the
/// UI-facing snapshot.
fn refresh_active(&mut self, before: Option<u32>) {
if self.active_id() != before {
self.flush_held();
}
self.sync_open();
self.publish();
}
/// Zero everything the host believes is held — on pad switch and detach. /// Zero everything the host believes is held — on pad switch and detach.
fn flush_held(&mut self) { fn flush_held(&mut self) {
if let Some(c) = &self.attached { if let Some(c) = &self.attached {
@@ -432,8 +528,7 @@ impl Worker<'_> {
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth). /// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) { fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return }; if let Some((_, pad)) = self.open.as_mut() {
if let Some(pad) = self.opened.get_mut(&id) {
use sdl3::sensor::SensorType; use sdl3::sensor::SensorType;
for s in [SensorType::Gyroscope, SensorType::Accelerometer] { for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
if unsafe { pad.has_sensor(s) } { if unsafe { pad.has_sensor(s) } {
@@ -459,9 +554,10 @@ impl Worker<'_> {
return; return;
}; };
let multi = self let multi = self
.opened .open
.get(&which) .as_ref()
.map(|p| p.touchpads_count() >= 2) .filter(|(id, _)| *id == which)
.map(|(_, p)| p.touchpads_count() >= 2)
.unwrap_or(false); .unwrap_or(false);
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)); let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
let surface = if multi { (touchpad as u8) + 1 } else { 0 }; let surface = if multi { (touchpad as u8) + 1 } else { 0 };
@@ -503,7 +599,6 @@ impl Worker<'_> {
list.reverse(); // most recent first — the Settings list order list.reverse(); // most recent first — the Settings list order
*self.pads_out.lock().unwrap() = list; *self.pads_out.lock().unwrap() = list;
*self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id)); *self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id));
*self.pinned_out.lock().unwrap() = self.pinned;
} }
/// Apply queued control-plane messages from the UI thread. Returns false when the /// Apply queued control-plane messages from the UI thread. Returns false when the
@@ -515,23 +610,22 @@ impl Worker<'_> {
self.attached = Some(c); self.attached = Some(c);
self.last_axis = [i32::MIN; 6]; self.last_axis = [i32::MIN; 6];
self.reset_chord(); // every session starts un-latched (Attach doesn't flush) self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
self.set_sensors(true); // The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open follows the churn events.
set_valve_hidapi(true);
self.sync_open();
} }
Ok(Ctl::Detach) => { Ok(Ctl::Detach) => {
self.flush_held(); self.flush_held();
self.set_sensors(false);
self.attached = None; self.attached = None;
self.sync_open(); // closes the held device
set_valve_hidapi(false);
} }
Ok(Ctl::Pin(id)) => { Ok(Ctl::Pin(key)) => {
let before = self.active_id(); let before = self.active_id();
self.pinned = id; self.pinned = key;
if self.active_id() != before { self.refresh_active(before);
self.flush_held();
if self.attached.is_some() {
self.set_sensors(true);
}
}
self.publish();
} }
Err(std::sync::mpsc::TryRecvError::Empty) => return true, Err(std::sync::mpsc::TryRecvError::Empty) => return true,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone
@@ -546,35 +640,22 @@ impl Worker<'_> {
let active = self.active_id(); let active = self.active_id();
match event { match event {
Event::ControllerDeviceAdded { which, .. } => { Event::ControllerDeviceAdded { which, .. } => {
if !self.opened.contains_key(&which) { if !self.order.contains(&which) {
match self self.order.push(which);
.subsystem if let Some(p) = self.pad_info(which) {
.open(sdl3::sys::joystick::SDL_JoystickID(which)) tracing::info!(name = p.name, "gamepad attached");
{
Ok(pad) => {
tracing::info!(
name = pad.name().unwrap_or_default(),
"gamepad attached"
);
self.opened.insert(which, pad);
self.order.push(which);
if self.attached.is_some() && self.active_id() == Some(which) {
self.set_sensors(true);
}
self.publish();
}
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
} }
self.refresh_active(active);
} }
} }
Event::ControllerDeviceRemoved { which, .. } => { Event::ControllerDeviceRemoved { which, .. } => {
if self.opened.remove(&which).is_some() { if self.order.contains(&which) {
self.order.retain(|&id| id != which); self.order.retain(|&id| id != which);
if active == Some(which) { if self.open.as_ref().map(|(id, _)| *id) == Some(which) {
self.flush_held(); self.open = None; // the device is gone; drop our handle
} }
tracing::info!("gamepad detached"); tracing::info!("gamepad detached");
self.publish(); self.refresh_active(active);
} }
} }
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => { Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
@@ -687,7 +768,7 @@ impl Worker<'_> {
}; };
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if pad == 0 { if pad == 0 {
if let Some(p) = self.active_id().and_then(|id| self.opened.get_mut(&id)) { if let Some((_, p)) = self.open.as_mut() {
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in // Surface a failed SDL rumble write: a swallowed error here (DualSense not in
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
// host logs the send side on 0xCA, so the two together pinpoint host-game vs // host logs the send side on 0xCA, so the two together pinpoint host-game vs
@@ -703,9 +784,12 @@ impl Worker<'_> {
} }
} }
while let Ok(hid) = connector.next_hidout(Duration::ZERO) { while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = self.active_id() else { continue }; let is_ds = self
let is_ds = self.pad_info(id).is_some_and(|p| p.is_dualsense()); .open
let Some(pad) = self.opened.get_mut(&id) else { .as_ref()
.and_then(|(id, _)| self.pad_info(*id))
.is_some_and(|p| p.is_dualsense());
let Some((_, pad)) = self.open.as_mut() else {
continue; continue;
}; };
match hid { match hid {
@@ -734,7 +818,6 @@ impl Worker<'_> {
fn run( fn run(
pads_out: &Mutex<Vec<PadInfo>>, pads_out: &Mutex<Vec<PadInfo>>,
active_out: &Mutex<Option<PadInfo>>, active_out: &Mutex<Option<PadInfo>>,
pinned_out: &Mutex<Option<u32>>,
ctl: &Receiver<Ctl>, ctl: &Receiver<Ctl>,
escape_tx: &async_channel::Sender<()>, escape_tx: &async_channel::Sender<()>,
disconnect_tx: &async_channel::Sender<()>, disconnect_tx: &async_channel::Sender<()>,
@@ -743,12 +826,10 @@ fn run(
// own thread. // own thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the // The Valve HIDAPI drivers start DISABLED (SDL defaults the Deck one ON, and its mere
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. On a Deck in Game // enumeration kills the Deck's trackpad-mouse system-wide — see set_valve_hidapi);
// Mode, Steam Input still holds the device — the user must disable Steam Input for this app (see // they are enabled for the duration of an attached session only.
// the Decky UX); on a desktop client (or a Deck with Steam Input off) the hints just work. set_valve_hidapi(false);
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
let sdl = sdl3::init().map_err(|e| e.to_string())?; let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
@@ -757,8 +838,7 @@ fn run(
subsystem, subsystem,
pads_out, pads_out,
active_out, active_out,
pinned_out, open: None,
opened: HashMap::new(),
order: Vec::new(), order: Vec::new(),
pinned: None, pinned: None,
attached: None, attached: None,
+7 -4
View File
@@ -265,13 +265,16 @@ impl SessionUi {
stop: self.stop.clone(), stop: self.stop.clone(),
inhibit_shortcuts: self.inhibit, inhibit_shortcuts: self.inhibit,
show_stats: self.show_stats, show_stats: self.show_stats,
chromeless: self.app.fullscreen,
title, title,
}); });
self.app.nav.push(&p.page); self.app.nav.push(&p.page);
// Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't // Streams start fullscreen by default (Settings toggle) — a streaming window with
// know it, so its header bar stays drawn. Enter GTK fullscreen explicitly — // chrome is never what anyone wants mid-game; F11 / the controller chord / the
// the stream page's `connect_fullscreened_notify` then hides all chrome. // top-edge header reveal lead back out. Gaming-Mode launches (`--fullscreen`)
if self.app.fullscreen { // fullscreen regardless: gamescope fullscreens the window at its level but GTK
// doesn't know it, so the header bar would stay drawn.
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
self.app.window.fullscreen(); self.app.window.fullscreen();
} }
self.page = Some(p); self.page = Some(p);
+25
View File
@@ -182,6 +182,10 @@ pub struct Settings {
/// Requested encoder bitrate (kbps); 0 = host default. /// Requested encoder bitrate (kbps); 0 = host default.
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
pub gamepad: String, pub gamepad: String,
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
pub forward_pad: String,
/// Which host compositor backend to request (advisory; the host falls back to /// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable). /// auto-detect when unavailable).
pub compositor: String, pub compositor: String,
@@ -201,6 +205,9 @@ pub struct Settings {
pub decoder: String, pub decoder: String,
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S). /// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
pub show_stats: bool, pub show_stats: bool,
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
pub fullscreen_on_stream: bool,
/// Experimental: the game-library browser ("Browse library…" on saved cards) — /// Experimental: the game-library browser ("Browse library…" on saved cards) —
/// mirrors the Apple client's "Show game library" toggle, default off. /// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool, pub library_enabled: bool,
@@ -230,6 +237,7 @@ impl Default for Settings {
refresh_hz: 0, refresh_hz: 0,
bitrate_kbps: 0, bitrate_kbps: 0,
gamepad: "auto".into(), gamepad: "auto".into(),
forward_pad: String::new(),
compositor: "auto".into(), compositor: "auto".into(),
inhibit_shortcuts: true, inhibit_shortcuts: true,
mic_enabled: false, mic_enabled: false,
@@ -237,6 +245,7 @@ impl Default for Settings {
codec: "auto".into(), codec: "auto".into(),
decoder: "auto".into(), decoder: "auto".into(),
show_stats: true, show_stats: true,
fullscreen_on_stream: true,
library_enabled: false, library_enabled: false,
} }
} }
@@ -263,3 +272,19 @@ impl Settings {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
#[test]
fn settings_forward_pad_defaults_empty() {
let old = r#"{"width":1280,"height":720,"refresh_hz":60,"bitrate_kbps":0,
"gamepad":"auto","compositor":"auto","inhibit_shortcuts":true,"mic_enabled":true}"#;
let s: Settings = serde_json::from_str(old).unwrap();
assert_eq!(s.forward_pad, "");
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(round.forward_pad, "");
}
}
+384 -81
View File
@@ -3,7 +3,7 @@
use crate::trust::Settings; use crate::trust::Settings;
use adw::prelude::*; use adw::prelude::*;
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect. /// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
@@ -25,7 +25,7 @@ const DECODERS: &[&str] = &["auto", "vaapi", "software"];
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page. /// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!( const APP_LICENSE: &str = concat!(
"punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n", "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
"================================ MIT ================================\n\n", "================================ MIT ================================\n\n",
include_str!("../../../LICENSE-MIT"), include_str!("../../../LICENSE-MIT"),
"\n\n=============================== Apache-2.0 ===============================\n\n", "\n\n=============================== Apache-2.0 ===============================\n\n",
@@ -39,7 +39,7 @@ const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt
/// from the primary menu (app.rs `win.about`). /// from the primary menu (app.rs `win.about`).
pub fn show_about(parent: &impl IsA<gtk::Widget>) { pub fn show_about(parent: &impl IsA<gtk::Widget>) {
let about = adw::AboutDialog::builder() let about = adw::AboutDialog::builder()
.application_name("punktfunk") .application_name("Punktfunk")
.developer_name("unom") .developer_name("unom")
.version(env!("CARGO_PKG_VERSION")) .version(env!("CARGO_PKG_VERSION"))
.website("https://git.unom.io/unom/punktfunk") .website("https://git.unom.io/unom/punktfunk")
@@ -67,6 +67,179 @@ pub fn show_about(parent: &impl IsA<gtk::Widget>) {
about.present(Some(parent)); about.present(Some(parent));
} }
/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers
/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown
/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel.
fn gamescope_session() -> bool {
std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
}
type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
/// A titled single-choice preference row. On a desktop this is a stock popover
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
struct ChoiceRow {
row: adw::PreferencesRow,
selected: Rc<Cell<u32>>,
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
/// after seeding, so programmatic `set_selected` during setup never fires it.
changed: ChangedFn,
/// Subpage mode only: the current value rendered as the row's suffix.
value_label: Option<gtk::Label>,
options: Rc<Vec<String>>,
}
impl ChoiceRow {
/// `inline` = subpage mode (gamescope): computed once per dialog via
/// [`gamescope_session`] and passed in so tests can drive both modes directly.
fn new(
dialog: &adw::PreferencesDialog,
inline: bool,
title: &str,
subtitle: &str,
options: &[&str],
) -> ChoiceRow {
let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
let selected = Rc::new(Cell::new(0u32));
let changed: ChangedFn = Rc::new(RefCell::new(None));
if !inline {
let row = adw::ComboRow::builder()
.title(title)
.subtitle(subtitle)
.model(&gtk::StringList::new(
&options.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let (sel, chg) = (selected.clone(), changed.clone());
row.connect_selected_notify(move |r| {
if sel.replace(r.selected()) != r.selected() {
if let Some(f) = chg.borrow().as_ref() {
f(r.selected());
}
}
});
return ChoiceRow {
row: row.upcast(),
selected,
changed,
value_label: None,
options,
};
}
let value = gtk::Label::builder().css_classes(["dim-label"]).build();
let row = adw::ActionRow::builder()
.title(title)
.subtitle(subtitle)
.activatable(true)
.build();
row.add_suffix(&value);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
{
let dialog = dialog.downgrade();
let (options, sel, chg, value) = (
options.clone(),
selected.clone(),
changed.clone(),
value.clone(),
);
let title = title.to_string();
row.connect_activated(move |_| {
let Some(dialog) = dialog.upgrade() else {
return;
};
let list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
for (i, opt) in options.iter().enumerate() {
let check = gtk::Image::from_icon_name("object-select-symbolic");
check.set_visible(i as u32 == sel.get());
let opt_row = adw::ActionRow::builder()
.title(opt)
.use_markup(false)
.activatable(true)
.build();
opt_row.add_suffix(&check);
let idx = i as u32;
let dlg = dialog.downgrade();
let (sel, chg, value, label) =
(sel.clone(), chg.clone(), value.clone(), opt.clone());
opt_row.connect_activated(move |_| {
let user_change = sel.replace(idx) != idx;
value.set_text(&label);
if user_change {
if let Some(f) = chg.borrow().as_ref() {
f(idx);
}
}
if let Some(d) = dlg.upgrade() {
d.pop_subpage();
}
});
list.append(&opt_row);
}
let clamp = adw::Clamp::builder()
.child(&list)
.margin_top(24)
.margin_bottom(24)
.margin_start(12)
.margin_end(12)
.build();
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&clamp)
.build();
let view = adw::ToolbarView::new();
view.add_top_bar(&adw::HeaderBar::new());
view.set_content(Some(&scroll));
dialog.push_subpage(&adw::NavigationPage::new(&view, &title));
});
}
let cr = ChoiceRow {
row: row.upcast(),
selected,
changed,
value_label: Some(value),
options,
};
cr.sync_value();
cr
}
/// Subpage mode: reflect the current selection in the row's suffix label.
fn sync_value(&self) {
if let Some(l) = &self.value_label {
let i = self.selected.get() as usize;
l.set_text(self.options.get(i).map(String::as_str).unwrap_or(""));
}
}
fn widget(&self) -> &adw::PreferencesRow {
&self.row
}
fn selected(&self) -> u32 {
self.selected.get()
}
fn set_selected(&self, i: u32) {
if let Some(combo) = self.row.downcast_ref::<adw::ComboRow>() {
combo.set_selected(i); // the notify handler syncs the cell
} else {
self.selected.set(i);
self.sync_value();
}
}
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
*self.changed.borrow_mut() = Some(Box::new(f));
}
}
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid /// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the experimental library toggle takes effect without a nav round-trip). /// there so the experimental library toggle takes effect without a nav round-trip).
pub fn show( pub fn show(
@@ -75,6 +248,11 @@ pub fn show(
gamepads: &crate::gamepad::GamepadService, gamepads: &crate::gamepad::GamepadService,
on_closed: impl Fn() + 'static, on_closed: impl Fn() + 'static,
) { ) {
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
// subpage onto it.
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
let inline = gamescope_session();
let page = adw::PreferencesPage::new(); let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build(); let stream = adw::PreferencesGroup::builder().title("Stream").build();
@@ -88,13 +266,13 @@ pub fn show(
} }
}) })
.collect(); .collect();
let res_row = adw::ComboRow::builder() let res_row = ChoiceRow::new(
.title("Resolution") &dialog,
.subtitle("The host creates a virtual output at exactly this size") inline,
.model(&gtk::StringList::new( "Resolution",
&res_names.iter().map(String::as_str).collect::<Vec<_>>(), "The host creates a virtual output at exactly this size",
)) &res_names.iter().map(String::as_str).collect::<Vec<_>>(),
.build(); );
let hz_names: Vec<String> = REFRESH let hz_names: Vec<String> = REFRESH
.iter() .iter()
.map(|&r| { .map(|&r| {
@@ -105,123 +283,153 @@ pub fn show(
} }
}) })
.collect(); .collect();
let hz_row = adw::ComboRow::builder() let hz_row = ChoiceRow::new(
.title("Refresh rate") &dialog,
.model(&gtk::StringList::new( inline,
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(), "Refresh rate",
)) "",
.build(); &hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0); let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
bitrate_row.set_title("Bitrate"); bitrate_row.set_title("Bitrate");
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high"); bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
let compositor_row = adw::ComboRow::builder() let compositor_row = ChoiceRow::new(
.title("Host compositor") &dialog,
.subtitle("Advisory — the host falls back to auto-detect when unavailable") inline,
.model(&gtk::StringList::new(&[ "Host compositor",
"Advisory — the host falls back to auto-detect when unavailable",
&[
"Automatic", "Automatic",
"KWin", "KWin",
"wlroots (Sway/Hyprland)", "wlroots (Sway/Hyprland)",
"Mutter (GNOME)", "Mutter (GNOME)",
"gamescope", "gamescope",
])) ],
.build(); );
let decoder_row = adw::ComboRow::builder() let decoder_row = ChoiceRow::new(
.title("Video decoder") &dialog,
.subtitle("Automatic tries VAAPI hardware decode, then software") inline,
.model(&gtk::StringList::new(&[ "Video decoder",
"Automatic tries VAAPI hardware decode, then software",
&[
"Automatic (VAAPI → software)", "Automatic (VAAPI → software)",
"Hardware (VAAPI)", "Hardware (VAAPI)",
"Software", "Software",
])) ],
.build(); );
let stats_row = adw::SwitchRow::builder() let stats_row = adw::SwitchRow::builder()
.title("Show statistics overlay") .title("Show statistics overlay")
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live") .subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
.build(); .build();
stream.add(&res_row); let fullscreen_row = adw::SwitchRow::builder()
stream.add(&hz_row); .title("Start streams in fullscreen")
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
.build();
stream.add(res_row.widget());
stream.add(hz_row.widget());
stream.add(&bitrate_row); stream.add(&bitrate_row);
stream.add(&compositor_row); stream.add(compositor_row.widget());
stream.add(&decoder_row); stream.add(decoder_row.widget());
stream.add(&fullscreen_row);
stream.add(&stats_row); stream.add(&stats_row);
let input = adw::PreferencesGroup::builder().title("Input").build(); let input = adw::PreferencesGroup::builder().title("Input").build();
// Which physical controller forwards as pad 0: automatic = the most recently // Which physical controller forwards as pad 0: automatic = the most recently connected
// connected; pinning survives until the app exits (Swift parity). // real pad (Steam's virtual pad skipped). A pin is persisted by stable key
// (`Settings::forward_pad`), so it survives restarts — and disconnects: an offline
// pinned pad keeps its entry here instead of silently snapping back to Automatic.
let pads = gamepads.pads(); let pads = gamepads.pads();
let saved_pin = settings.borrow().forward_pad.clone();
let mut pad_names = vec!["Automatic (most recent)".to_string()]; let mut pad_names = vec!["Automatic (most recent)".to_string()];
pad_names.extend(pads.iter().map(|p| { let mut pad_keys: Vec<String> = Vec::new();
for p in &pads {
let kind = p.kind_label(); let kind = p.kind_label();
if kind.is_empty() { pad_names.push(if kind.is_empty() {
p.name.clone() p.name.clone()
} else { } else {
format!("{} · {kind}", p.name) format!("{} · {kind}", p.name)
} });
})); pad_keys.push(p.key.clone());
let forward_row = adw::ComboRow::builder() }
.title("Forwarded controller") if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) {
.subtitle(if pads.is_empty() { let name = saved_pin
.splitn(3, ':')
.nth(2)
.unwrap_or("Saved controller");
pad_names.push(format!("{name} (not connected)"));
pad_keys.push(saved_pin.clone());
}
let forward_row = ChoiceRow::new(
&dialog,
inline,
"Forwarded controller",
if pads.is_empty() {
"No controllers detected" "No controllers detected"
} else { } else {
"Exactly one controller is forwarded to the host" "Exactly one controller is forwarded to the host"
}) },
.model(&gtk::StringList::new( &pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(), );
)) let pinned_i = pad_keys
.build(); .iter()
let pinned_i = gamepads .position(|k| k == &saved_pin)
.pinned()
.and_then(|id| pads.iter().position(|p| p.id == id))
.map_or(0, |i| i + 1); .map_or(0, |i| i + 1);
forward_row.set_selected(pinned_i as u32); forward_row.set_selected(pinned_i as u32);
// The dialog-local choice, written into Settings on close (reading the service back
// would race its worker thread applying the Pin message).
let chosen_pin: Rc<RefCell<String>> = Rc::new(RefCell::new(saved_pin));
{ {
let svc = gamepads.clone(); let svc = gamepads.clone();
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect(); let keys = pad_keys.clone();
forward_row.connect_selected_notify(move |row| { let chosen = chosen_pin.clone();
let sel = row.selected() as usize; forward_row.connect_changed(move |sel| {
svc.set_pinned(if sel == 0 { let key = if sel == 0 {
None None
} else { } else {
ids.get(sel - 1).copied() keys.get(sel as usize - 1).cloned()
}); };
*chosen.borrow_mut() = key.clone().unwrap_or_default();
svc.set_pinned(key);
}); });
} }
let pad_row = adw::ComboRow::builder() let pad_row = ChoiceRow::new(
.title("Gamepad type") &dialog,
.subtitle("The virtual pad the host creates — Automatic matches the physical pad") inline,
.model(&gtk::StringList::new(&[ "Gamepad type",
"The virtual pad the host creates — Automatic matches the physical pad",
&[
"Automatic", "Automatic",
"Xbox 360", "Xbox 360",
"DualSense", "DualSense",
"Xbox One", "Xbox One",
"DualShock 4", "DualShock 4",
])) ],
.build(); );
let inhibit_row = adw::SwitchRow::builder() let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts") .title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured") .subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build(); .build();
input.add(&forward_row); input.add(forward_row.widget());
input.add(&pad_row); input.add(pad_row.widget());
input.add(&inhibit_row); input.add(&inhibit_row);
let audio = adw::PreferencesGroup::builder().title("Audio").build(); let audio = adw::PreferencesGroup::builder().title("Audio").build();
let surround_row = adw::ComboRow::builder() let surround_row = ChoiceRow::new(
.title("Audio channels") &dialog,
.subtitle("Request stereo or surround (the host downmixes if its output has fewer)") inline,
.model(&gtk::StringList::new(&[ "Audio channels",
"Stereo", "Request stereo or surround (the host downmixes if its output has fewer)",
"5.1 Surround", &["Stereo", "5.1 Surround", "7.1 Surround"],
"7.1 Surround", );
])) audio.add(surround_row.widget());
.build(); let codec_row = ChoiceRow::new(
audio.add(&surround_row); &dialog,
let codec_row = adw::ComboRow::builder() inline,
.title("Video codec") "Video codec",
.subtitle("Preferred codec — the host falls back if it can't encode this one") "Preferred codec — the host falls back if it can't encode this one",
.model(&gtk::StringList::new(CODEC_LABELS)) CODEC_LABELS,
.build(); );
stream.add(&codec_row); stream.add(codec_row.widget());
let mic_row = adw::SwitchRow::builder() let mic_row = adw::SwitchRow::builder()
.title("Stream microphone") .title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone") .subtitle("Send the default input device to the host's virtual microphone")
@@ -268,6 +476,7 @@ pub fn show(
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
decoder_row.set_selected(dec_i as u32); decoder_row.set_selected(dec_i as u32);
stats_row.set_active(s.show_stats); stats_row.set_active(s.show_stats);
fullscreen_row.set_active(s.fullscreen_on_stream);
inhibit_row.set_active(s.inhibit_shortcuts); inhibit_row.set_active(s.inhibit_shortcuts);
mic_row.set_active(s.mic_enabled); mic_row.set_active(s.mic_enabled);
library_row.set_active(s.library_enabled); library_row.set_active(s.library_enabled);
@@ -280,8 +489,6 @@ pub fn show(
codec_row.set_selected(codec_i as u32); codec_row.set_selected(codec_i as u32);
} }
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.add(&page); dialog.add(&page);
dialog.connect_closed(move |_| { dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut(); let mut s = settings.borrow_mut();
@@ -290,10 +497,12 @@ pub fn show(
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
s.forward_pad = chosen_pin.borrow().clone();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string(); .to_string();
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
s.show_stats = stats_row.is_active(); s.show_stats = stats_row.is_active();
s.fullscreen_on_stream = fullscreen_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active(); s.inhibit_shortcuts = inhibit_row.is_active();
s.mic_enabled = mic_row.is_active(); s.mic_enabled = mic_row.is_active();
s.audio_channels = match surround_row.selected() { s.audio_channels = match surround_row.selected() {
@@ -309,3 +518,97 @@ pub fn show(
}); });
dialog.present(Some(parent)); dialog.present(Some(parent));
} }
#[cfg(test)]
mod tests {
use super::*;
/// Depth-first search for an [`adw::ActionRow`] with the given title.
fn find_action_row(root: &gtk::Widget, title: &str) -> Option<adw::ActionRow> {
if let Some(row) = root.downcast_ref::<adw::ActionRow>() {
if row.title() == title {
return Some(row.clone());
}
}
let mut child = root.first_child();
while let Some(c) = child {
if let Some(hit) = find_action_row(&c, title) {
return Some(hit);
}
child = c.next_sibling();
}
None
}
fn pump() {
let ctx = gtk::glib::MainContext::default();
while ctx.iteration(false) {}
}
/// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test
/// its own thread, so the display tests can't be split). Gamescope mode: activating the
/// row pushes the in-window selection subpage; activating an option updates the
/// selection + suffix label, fires the change callback, and pops the subpage. Combo
/// mode: cell sync + change callback. Needs a display — run manually with
/// `cargo test -p punktfunk-client-linux -- --ignored` on a session box.
#[test]
#[ignore = "needs a Wayland/X display"]
fn choice_row_modes() {
assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display");
let win = adw::Window::new();
let dialog = adw::PreferencesDialog::new();
let page = adw::PreferencesPage::new();
let group = adw::PreferencesGroup::new();
let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]);
group.add(row.widget());
page.add(&group);
dialog.add(&page);
let fired = Rc::new(Cell::new(u32::MAX));
{
let f = fired.clone();
row.connect_changed(move |i| f.set(i));
}
win.present();
dialog.present(Some(&win));
pump();
// Suffix label reflects the seed.
assert_eq!(row.value_label.as_ref().unwrap().text(), "A");
// Row activation → subpage with the options list.
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.emit_by_name::<()>("activated", &[]);
pump();
let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing");
// Option activation → state + label + callback, subpage popped.
opt_b.emit_by_name::<()>("activated", &[]);
pump();
assert_eq!(row.selected(), 1);
assert_eq!(fired.get(), 1);
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
// Re-activating shows the check on the new selection (fresh subpage each time).
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.emit_by_name::<()>("activated", &[]);
pump();
assert!(find_action_row(dialog.upcast_ref(), "B").is_some());
// Desktop (ComboRow) mode: cell sync + change callback on selection change.
let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]);
combo.set_selected(1);
assert_eq!(combo.selected(), 1);
let combo_fired = Rc::new(Cell::new(u32::MAX));
{
let f = combo_fired.clone();
combo.connect_changed(move |i| f.set(i));
}
combo.set_selected(0);
assert_eq!(combo.selected(), 0);
assert_eq!(combo_fired.get(), 0);
}
}
+188 -42
View File
@@ -34,6 +34,9 @@ pub struct StreamPage {
/// Median capture→paintable-set latency (ms) over the frame consumer's last 1 s /// Median capture→paintable-set latency (ms) over the frame consumer's last 1 s
/// window — written there, folded into the OSD on each `Stats` event. /// window — written there, folded into the OSD on each `Stats` event.
present_ms: Rc<Cell<f32>>, present_ms: Rc<Cell<f32>>,
/// The stream is HDR (PQ) right now — set by the frame consumer from each frame's
/// signaling (the host can flip SDR↔HDR mid-session, in-band).
hdr: Rc<Cell<bool>>,
} }
impl StreamPage { impl StreamPage {
@@ -51,6 +54,9 @@ impl StreamPage {
line.push_str(" · "); line.push_str(" · ");
line.push_str(s.decoder); line.push_str(s.decoder);
} }
if self.hdr.get() {
line.push_str(" · HDR");
}
self.stats_label.set_text(&line); self.stats_label.set_text(&line);
} }
} }
@@ -72,6 +78,12 @@ pub struct StreamPageArgs {
pub inhibit_shortcuts: bool, pub inhibit_shortcuts: bool,
/// Show the stats OSD initially (Settings); Ctrl+Alt+Shift+S toggles it live. /// Show the stats OSD initially (Settings); Ctrl+Alt+Shift+S toggles it live.
pub show_stats: bool, pub show_stats: bool,
/// Gaming-Mode launch (`--fullscreen` / Deck env): build the page with NO header bar
/// at all. gamescope displays the window fullscreen but does not reliably ACK the
/// xdg_toplevel fullscreen state back, so anything keyed on `is_fullscreen()` (the
/// reveal-on-notify chrome hiding) may never fire — the title bar would stay drawn
/// over the stream. Chrome-less by construction cannot regress that way.
pub chromeless: bool,
pub title: String, pub title: String,
} }
@@ -184,9 +196,10 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
stop, stop,
inhibit_shortcuts, inhibit_shortcuts,
show_stats, show_stats,
chromeless,
title, title,
} = args; } = args;
let w = build_widgets(&window, &title); let w = build_widgets(&window, &title, chromeless);
w.stats_label.set_visible(show_stats); w.stats_label.set_visible(show_stats);
let capture = Rc::new(Capture { let capture = Rc::new(Capture {
@@ -202,10 +215,20 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
}); });
let present_ms = Rc::new(Cell::new(0.0f32)); let present_ms = Rc::new(Cell::new(0.0f32));
spawn_frame_consumer(&w.picture, frames, clock_offset_ns, present_ms.clone()); let hdr = Rc::new(Cell::new(false));
spawn_frame_consumer(
&w.picture,
frames,
clock_offset_ns,
present_ms.clone(),
hdr.clone(),
);
attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label); attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label);
attach_mouse(&w.overlay, &capture); attach_mouse(&w.overlay, &capture);
attach_scroll(&w.overlay, &capture); attach_scroll(&w.overlay, &capture);
if !chromeless {
attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture);
}
let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture); let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture);
let escape_future = spawn_escape_watch(&window, &capture, escape_rx); let escape_future = spawn_escape_watch(&window, &capture, escape_rx);
let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx); let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx);
@@ -222,6 +245,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
page: w.page, page: w.page,
stats_label: w.stats_label, stats_label: w.stats_label,
present_ms, present_ms,
hdr,
} }
} }
@@ -231,6 +255,7 @@ struct PageWidgets {
stats_label: gtk::Label, stats_label: gtk::Label,
hint: gtk::Label, hint: gtk::Label,
overlay: gtk::Overlay, overlay: gtk::Overlay,
toolbar: adw::ToolbarView,
page: adw::NavigationPage, page: adw::NavigationPage,
/// Fullscreen-notify handler on the shared window — disconnected on page teardown. /// Fullscreen-notify handler on the shared window — disconnected on page teardown.
fs_handler: glib::SignalHandlerId, fs_handler: glib::SignalHandlerId,
@@ -238,7 +263,8 @@ struct PageWidgets {
/// The offloaded picture under an overlay (stats HUD, capture hint, fullscreen hint), a /// The offloaded picture under an overlay (stats HUD, capture hint, fullscreen hint), a
/// header bar with the fullscreen toggle, and the window's fullscreen behavior. /// header bar with the fullscreen toggle, and the window's fullscreen behavior.
fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets { /// `chromeless` (Gaming Mode) builds NO header bar at all — see `StreamPageArgs`.
fn build_widgets(window: &adw::ApplicationWindow, title: &str, chromeless: bool) -> PageWidgets {
let picture = gtk::Picture::new(); let picture = gtk::Picture::new();
picture.set_content_fit(gtk::ContentFit::Contain); picture.set_content_fit(gtk::ContentFit::Contain);
@@ -265,12 +291,15 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
hint.set_margin_bottom(24); hint.set_margin_bottom(24);
hint.set_visible(false); hint.set_visible(false);
// Flashed when entering fullscreen — the only exit affordances once the header bar is // Flashed when entering fullscreen — the exit affordances once the header bar is
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the // hidden (F11 on a keyboard; the top-edge pointer reveal for mouse/trackpad-only
// only way out on a Steam Deck). // devices; the L1+R1+Start+Select chord on a controller). Gaming Mode has no F11,
let fs_hint = gtk::Label::new(Some( // no header to reveal, and Steam owns window management — only the chord applies.
"F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)", let fs_hint = gtk::Label::new(Some(if chromeless {
)); "L1 + R1 + Start + Select — leave the stream (hold to disconnect)"
} else {
"F11 · mouse to the top edge · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)"
}));
fs_hint.add_css_class("osd"); fs_hint.add_css_class("osd");
fs_hint.set_halign(gtk::Align::Center); fs_hint.set_halign(gtk::Align::Center);
fs_hint.set_valign(gtk::Align::Start); fs_hint.set_valign(gtk::Align::Start);
@@ -284,23 +313,33 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
overlay.add_overlay(&fs_hint); overlay.add_overlay(&fs_hint);
overlay.set_focusable(true); overlay.set_focusable(true);
let header = adw::HeaderBar::new(); let toolbar = adw::ToolbarView::new();
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic"); if !chromeless {
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)")); let header = adw::HeaderBar::new();
{ let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
let window = window.clone(); fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
fullscreen_btn.connect_clicked(move |_| { {
if window.is_fullscreen() { let window = window.clone();
window.unfullscreen(); fullscreen_btn.connect_clicked(move |_| {
} else { if window.is_fullscreen() {
window.fullscreen(); window.unfullscreen();
} } else {
window.fullscreen();
}
});
}
header.pack_end(&fullscreen_btn);
toolbar.add_top_bar(&header);
} else {
// No header exists to hide, and gamescope may never ACK fullscreen — flash the
// chord hint when the stream maps instead of on the fullscreened notify.
let fs_hint = fs_hint.clone();
overlay.connect_map(move |_| {
fs_hint.set_visible(true);
let fs_hint = fs_hint.clone();
glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false));
}); });
} }
header.pack_end(&fullscreen_btn);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&overlay)); toolbar.set_content(Some(&overlay));
// Fullscreen = the stream and nothing else. (Window handlers are disconnected when // Fullscreen = the stream and nothing else. (Window handlers are disconnected when
// the page dies — the window outlives every session.) // the page dies — the window outlives every session.)
@@ -310,6 +349,9 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
window.connect_fullscreened_notify(move |w| { window.connect_fullscreened_notify(move |w| {
let fs = w.is_fullscreen(); let fs = w.is_fullscreen();
toolbar.set_reveal_top_bars(!fs); toolbar.set_reveal_top_bars(!fs);
if chromeless {
return; // the map handler above owns the hint; there is no bar to reveal
}
if fs { if fs {
fs_hint.set_visible(true); fs_hint.set_visible(true);
let fs_hint = fs_hint.clone(); let fs_hint = fs_hint.clone();
@@ -331,11 +373,48 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
stats_label, stats_label,
hint, hint,
overlay, overlay,
toolbar,
page, page,
fs_handler, fs_handler,
} }
} }
/// Fullscreen chrome recovery for pointer-only devices (a Deck desktop has no F11): while
/// fullscreen and NOT captured, bumping the pointer against the top edge reveals the header
/// bar (back button, fullscreen toggle); moving back into the stream hides it again. While
/// captured the pointer belongs to the host — nothing reveals, and a still-revealed bar is
/// re-hidden on the first captured movement (release capture first: Ctrl+Alt+Shift+Q).
fn attach_edge_reveal(
toolbar: &adw::ToolbarView,
overlay: &gtk::Overlay,
window: &adw::ApplicationWindow,
capture: &Rc<Capture>,
) {
let motion = gtk::EventControllerMotion::new();
let toolbar = toolbar.clone();
let window = window.clone();
let cap = capture.clone();
motion.connect_motion(move |_, _x, y| {
if !window.is_fullscreen() {
return; // windowed chrome is the fullscreened-notify handler's business
}
if cap.captured.get() {
if toolbar.reveals_top_bars() {
toolbar.set_reveal_top_bars(false);
}
return;
}
if y <= 2.0 {
toolbar.set_reveal_top_bars(true);
} else if y > 4.0 && toolbar.reveals_top_bars() {
// Once revealed the content sits below the bar, so y stays small while the
// pointer hovers the boundary; anything deeper means the user moved back in.
toolbar.set_reveal_top_bars(false);
}
});
overlay.add_controller(motion);
}
/// Frame consumer: each decoded frame becomes the picture's paintable as soon as it /// Frame consumer: each decoded frame becomes the picture's paintable as soon as it
/// arrives (the session's tiny `force_send` queue already dropped anything older); GTK /// arrives (the session's tiny `force_send` queue already dropped anything older); GTK
/// then draws whatever paintable is current on its own frame clock. Ends itself when the /// then draws whatever paintable is current on its own frame clock. Ends itself when the
@@ -347,23 +426,67 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
/// capture→paintable-SET — GTK's own present adds one compositor cycle after this. The /// capture→paintable-SET — GTK's own present adds one compositor cycle after this. The
/// 1 s p50 lands on the stats OSD (via `present_ms`) and in a "present window" debug /// 1 s p50 lands on the stats OSD (via `present_ms`) and in a "present window" debug
/// line for headless validation. /// line for headless validation.
/// One-entry cache of `ColorDesc` → `GdkColorState` (signaling changes at most on an
/// SDR↔HDR flip, never per frame).
#[derive(Default)]
struct ColorStateCache(Option<(crate::video::ColorDesc, Option<gdk::ColorState>)>);
impl ColorStateCache {
/// The color state for a frame's signaling. `rgb` = the pixels are already full-range
/// RGB (the CPU path — only transfer + primaries remain meaningful); else YUV, where
/// H.273 "unspecified" (2) fills in as BT.709 limited, the host's SDR default. `None`
/// = GDK can't represent the combo — the caller's default (sRGB) applies, which
/// matches the pre-color-management behavior.
fn get(&mut self, desc: crate::video::ColorDesc, rgb: bool) -> Option<gdk::ColorState> {
if let Some((cached, state)) = &self.0 {
if *cached == desc {
return state.clone();
}
}
let def = |v: u8, d: u32| if v == 2 { d } else { u32::from(v) };
let cicp = gdk::CicpParams::new();
if rgb {
cicp.set_color_primaries(def(desc.primaries, 1));
cicp.set_transfer_function(def(desc.transfer, 13)); // 13 = sRGB
cicp.set_matrix_coefficients(0); // identity — the matrix is already undone
cicp.set_range(gdk::CicpRange::Full);
} else {
cicp.set_color_primaries(def(desc.primaries, 1));
cicp.set_transfer_function(def(desc.transfer, 1));
cicp.set_matrix_coefficients(def(desc.matrix, 1));
cicp.set_range(if desc.full_range {
gdk::CicpRange::Full
} else {
gdk::CicpRange::Narrow
});
}
let state = cicp.build_color_state().ok();
if state.is_none() {
tracing::warn!(
?desc,
"GDK can't represent this colour signaling — using default"
);
}
self.0 = Some((desc, state.clone()));
state
}
}
fn spawn_frame_consumer( fn spawn_frame_consumer(
picture: &gtk::Picture, picture: &gtk::Picture,
frames: async_channel::Receiver<DecodedFrame>, frames: async_channel::Receiver<DecodedFrame>,
clock_offset_ns: i64, clock_offset_ns: i64,
present_ms: Rc<Cell<f32>>, present_ms: Rc<Cell<f32>>,
hdr: Rc<Cell<bool>>,
) { ) {
let picture = picture.downgrade(); let picture = picture.downgrade();
// The host encodes BT.709 limited-range; without an explicit color state GDK // The colour state follows the FRAMES' own signaling (the Windows host switches an HDR
// would convert NV12 dmabufs with the (BT.601) dmabuf default. // desktop to BT.2020 PQ in-band while the Welcome still says SDR): unspecified falls
let rec709 = { // back to BT.709 limited — without an explicit state GDK would convert NV12 dmabufs
let cicp = gdk::CicpParams::new(); // with the (BT.601) dmabuf default. Cached per distinct signaling; a change mid-stream
cicp.set_color_primaries(1); // (SDR↔HDR flip) just rebuilds once.
cicp.set_transfer_function(1); let mut yuv_state = ColorStateCache::default();
cicp.set_matrix_coefficients(1); let mut rgb_state = ColorStateCache::default();
cicp.set_range(gdk::CicpRange::Narrow);
cicp.build_color_state().ok()
};
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let mut win_lat_us: Vec<u64> = Vec::with_capacity(256); let mut win_lat_us: Vec<u64> = Vec::with_capacity(256);
let mut win_start = Instant::now(); let mut win_start = Instant::now();
@@ -372,16 +495,39 @@ fn spawn_frame_consumer(
break; break;
}; };
let mut presented = false; let mut presented = false;
match &f.image {
DecodedImage::Cpu(c) => hdr.set(c.color.is_pq()),
DecodedImage::Dmabuf(d) => hdr.set(d.color.is_pq()),
}
match f.image { match f.image {
DecodedImage::Cpu(c) => { DecodedImage::Cpu(c) => {
let bytes = glib::Bytes::from_owned(c.rgba); let bytes = glib::Bytes::from_owned(c.rgba);
let tex = gdk::MemoryTexture::new( // swscale undid the YUV matrix (full-range RGB) — but a PQ/BT.2020
c.width as i32, // stream keeps transfer + primaries baked in, so tag the texture and
c.height as i32, // let GTK tone-map. Plain SDR keeps the untagged (sRGB) fast path.
gdk::MemoryFormat::R8g8b8a8, let tagged = (c.color.is_pq() || c.color.primaries == 9)
&bytes, .then(|| rgb_state.get(c.color, true))
c.stride, .flatten();
); let tex: gdk::Texture = if let Some(state) = tagged {
gdk::MemoryTextureBuilder::new()
.set_width(c.width as i32)
.set_height(c.height as i32)
.set_format(gdk::MemoryFormat::R8g8b8a8)
.set_bytes(Some(&bytes))
.set_stride(c.stride)
.set_color_state(&state)
.build()
.upcast()
} else {
gdk::MemoryTexture::new(
c.width as i32,
c.height as i32,
gdk::MemoryFormat::R8g8b8a8,
&bytes,
c.stride,
)
.upcast()
};
picture.set_paintable(Some(&tex)); picture.set_paintable(Some(&tex));
presented = true; presented = true;
} }
@@ -393,7 +539,7 @@ fn spawn_frame_consumer(
.set_fourcc(d.fourcc) .set_fourcc(d.fourcc)
.set_modifier(d.modifier) .set_modifier(d.modifier)
.set_n_planes(d.planes.len() as u32) .set_n_planes(d.planes.len() as u32)
.set_color_state(rec709.as_ref()); .set_color_state(yuv_state.get(d.color, false).as_ref());
for (i, p) in d.planes.iter().enumerate() { for (i, p) in d.planes.iter().enumerate() {
b = unsafe { b.set_fd(i as u32, p.fd) } b = unsafe { b.set_fd(i as u32, p.fd) }
.set_offset(i as u32, p.offset) .set_offset(i as u32, p.offset)
+105 -14
View File
@@ -37,6 +37,43 @@ pub enum DecodedImage {
Dmabuf(DmabufFrame), Dmabuf(DmabufFrame),
} }
/// The stream's colour signaling, read PER-FRAME from the decoder (HEVC VUI → the
/// `AVFrame` CICP fields). The Windows host switches an HDR desktop to Main10 BT.2020 PQ
/// **in-band** (the Welcome still says SDR — clients are expected to follow the VUI, as
/// the Windows/Apple/Android clients do), so rendering must follow the frames, not the
/// handshake — else PQ content drawn as BT.709 comes out washed out and desaturated.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct ColorDesc {
/// H.273 code points as signaled (2 = unspecified → the renderer picks the SDR default).
pub primaries: u8,
pub transfer: u8,
pub matrix: u8,
pub full_range: bool,
}
impl ColorDesc {
/// Read the CICP fields off a raw decoded frame.
///
/// # Safety
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
unsafe {
ColorDesc {
primaries: (*frame).color_primaries as u32 as u8,
transfer: (*frame).color_trc as u32 as u8,
matrix: (*frame).colorspace as u32 as u8,
full_range: (*frame).color_range == ffmpeg::ffi::AVColorRange::AVCOL_RANGE_JPEG,
}
}
}
/// PQ (SMPTE ST.2084) transfer — the HDR10 signal.
pub fn is_pq(&self) -> bool {
self.transfer == 16
}
}
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride). /// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
pub struct CpuFrame { pub struct CpuFrame {
pub width: u32, pub width: u32,
@@ -44,6 +81,10 @@ pub struct CpuFrame {
/// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD). /// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD).
pub stride: usize, pub stride: usize,
pub rgba: Vec<u8>, pub rgba: Vec<u8>,
/// Signaling of the source frame. swscale already undid the YUV matrix + range (the
/// pixels are full-range RGB), but a PQ/BT.2020 stream keeps its transfer + primaries
/// baked in — the presenter tags the texture so GTK tone-maps it.
pub color: ColorDesc,
} }
/// A decoded frame still on the GPU: dmabuf fds + plane layout for /// A decoded frame still on the GPU: dmabuf fds + plane layout for
@@ -57,6 +98,9 @@ pub struct DmabufFrame {
pub fourcc: u32, pub fourcc: u32,
pub modifier: u64, pub modifier: u64,
pub planes: Vec<DmabufPlane>, pub planes: Vec<DmabufPlane>,
/// Signaling of the source frame — drives the `GdkDmabufTexture` color state (BT.709
/// narrow for SDR, BT.2020 PQ for an HDR stream).
pub color: ColorDesc,
pub guard: DrmFrameGuard, pub guard: DrmFrameGuard,
} }
@@ -174,8 +218,9 @@ impl Decoder {
struct SoftwareDecoder { struct SoftwareDecoder {
decoder: ffmpeg::decoder::Video, decoder: ffmpeg::decoder::Video,
/// Rebuilt whenever the decoded format/size changes (mid-stream `Reconfigure`). /// Rebuilt whenever the decoded format/size — or the colour signaling (a mid-stream
sws: Option<(scaling::Context, Pixel, u32, u32)>, /// SDR↔HDR flip) — changes.
sws: Option<(scaling::Context, Pixel, u32, u32, ColorDesc)>,
} }
impl SoftwareDecoder { impl SoftwareDecoder {
@@ -209,31 +254,41 @@ impl SoftwareDecoder {
fn convert_rgba(&mut self, frame: &AvFrame) -> Result<CpuFrame> { fn convert_rgba(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
let (fmt, w, h) = (frame.format(), frame.width(), frame.height()); let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
let rebuild = // SAFETY: `frame.as_ptr()` is the decoder-owned live AVFrame for this call.
!matches!(&self.sws, Some((_, f, sw, sh)) if *f == fmt && *sw == w && *sh == h); let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) };
let rebuild = !matches!(&self.sws,
Some((_, f, sw, sh, c)) if *f == fmt && *sw == w && *sh == h && *c == color);
if rebuild { if rebuild {
let mut ctx = let mut ctx =
scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT) scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
.context("swscale context")?; .context("swscale context")?;
// swscale defaults to BT.601 coefficients, but our SDR HEVC stream is BT.709 limited // swscale defaults to BT.601 coefficients — set them from the FRAME's signaling
// range (the host signals BT.709 in the VUI). Without this, YUV→RGB decodes with BT.601 // (unspecified → BT.709 limited, the host's SDR default; a Windows HDR desktop
// and SDR colours shift (greens/reds off). Source = limited/studio YUV, destination = // streams BT.2020 in-band). Without this, YUV→RGB decodes with the wrong matrix
// full-range RGB. Inverse of the host's RGB→YUV CSC (encode/vaapi.rs). // and colours shift. Destination = full-range RGB; the transfer function stays
// baked in (the presenter tags PQ textures so GTK applies the EOTF).
const SWS_CS_ITU709: i32 = 1; const SWS_CS_ITU709: i32 = 1;
const SWS_CS_ITU601: i32 = 5;
const SWS_CS_BT2020: i32 = 9;
let cs = match color.matrix {
9 | 10 => SWS_CS_BT2020,
5 | 6 => SWS_CS_ITU601,
_ => SWS_CS_ITU709,
};
unsafe { unsafe {
let cs709 = ffmpeg::ffi::sws_getCoefficients(SWS_CS_ITU709); let coeffs = ffmpeg::ffi::sws_getCoefficients(cs);
ffmpeg::ffi::sws_setColorspaceDetails( ffmpeg::ffi::sws_setColorspaceDetails(
ctx.as_mut_ptr(), ctx.as_mut_ptr(),
cs709, // inv_table: source (YUV) coefficients — BT.709 coeffs, // inv_table: source (YUV) coefficients per the VUI
0, // srcRange: 0 = limited/studio (MPEG) color.full_range as i32, // srcRange: 0 = limited/studio (MPEG)
cs709, // table: destination coefficients (ignored for RGB output) coeffs, // table: destination coefficients (ignored for RGB output)
1, // dstRange: 1 = full-range RGB 1, // dstRange: 1 = full-range RGB
0, 0,
1 << 16, 1 << 16,
1 << 16, // brightness, contrast, saturation (defaults) 1 << 16, // brightness, contrast, saturation (defaults)
); );
} }
self.sws = Some((ctx, fmt, w, h)); self.sws = Some((ctx, fmt, w, h, color));
} }
let (sws, ..) = self.sws.as_mut().unwrap(); let (sws, ..) = self.sws.as_mut().unwrap();
// Single-pass conversion: swscale writes straight into the Vec the texture will // Single-pass conversion: swscale writes straight into the Vec the texture will
@@ -290,6 +345,7 @@ impl SoftwareDecoder {
height: h, height: h,
stride: dst_linesize[0] as usize, stride: dst_linesize[0] as usize,
rgba, rgba,
color,
}) })
} }
} }
@@ -474,6 +530,9 @@ impl VaapiDecoder {
fourcc, fourcc,
modifier, modifier,
planes, planes,
// SAFETY: `self.frame` is the live decoded AVFrame (unref'd only after
// this returns); plain CICP field reads.
color: ColorDesc::from_raw(self.frame),
guard, guard,
}) })
} }
@@ -555,4 +614,36 @@ mod tests {
None None
); );
} }
/// The wire → `ColorDesc` plumbing: an HDR10 stream's VUI (BT.2020 primaries, PQ
/// transfer, BT.2020-NCL matrix, limited range) must arrive on the decoded frame —
/// this is what the Windows host emits in-band for an HDR desktop, and mis-rendering
/// it as BT.709 is the washed-out-colors bug. Fixture: one 64×64 Main10 IDR
/// (`tests/pq-frame.h265`, x265 with explicit VUI).
#[test]
fn software_decode_carries_pq_signaling() {
let au = include_bytes!("../tests/pq-frame.h265");
let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder");
let mut got = dec.decode(au).expect("decode");
if got.is_none() {
// Low-delay decoders may still hold the frame until a flush — send EOF.
dec.decoder.send_eof().ok();
let mut frame = AvFrame::empty();
if dec.decoder.receive_frame(&mut frame).is_ok() {
got = Some(dec.convert_rgba(&frame).expect("convert"));
}
}
let f = got.expect("no frame decoded from the PQ fixture");
assert_eq!(
f.color,
ColorDesc {
primaries: 9,
transfer: 16,
matrix: 9,
full_range: false
}
);
assert!(f.color.is_pq());
assert_eq!((f.width, f.height), (64, 64));
}
} }
Binary file not shown.
+6
View File
@@ -15,6 +15,10 @@ quinn = "0.11"
anyhow = "1" anyhow = "1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# The log ring (log_capture.rs) normalizes `log`-crate events off the bridge's "log" shim target
# back to the real module path, so the console's target column and the ring's noise gate see
# `mdns_sd::…` instead of "log".
tracing-log = "0.2"
axum = "0.8" axum = "0.8"
mdns-sd = "0.20" mdns-sd = "0.20"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@@ -64,6 +68,8 @@ tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1" http-body-util = "0.1"
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs). # Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
tempfile = "3" tempfile = "3"
# Emit `log`-crate records through the tracing-log bridge in the log_capture tests.
log = "0.4"
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround # Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The # (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
+78 -10
View File
@@ -8,6 +8,10 @@
//! //!
//! The ring keeps the *newest* [`CAPACITY`] entries (a log tail — unlike the stats recorder, //! The ring keeps the *newest* [`CAPACITY`] entries (a log tail — unlike the stats recorder,
//! which keeps the head of a capture). Readers poll with an `after` sequence cursor. //! which keeps the head of a capture). Readers poll with an `after` sequence cursor.
//!
//! `log`-crate events (arriving via the tracing-log bridge) are normalized to their real module
//! path, and known-chatty third-party targets ([`NOISY_DEBUG_TARGETS`]) are demoted to
//! INFO-and-up so ambient LAN noise can't evict the tail the ring exists to preserve.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -121,6 +125,21 @@ pub fn ring() -> &'static LogRing {
RING.get_or_init(LogRing::new) RING.get_or_init(LogRing::new)
} }
/// Targets whose DEBUG/TRACE output is steady-state per-packet chatter, not diagnostics — left
/// in, they evict the entire ring tail (mdns-sd DEBUG-logs every multicast packet it can't parse,
/// so one chatty AirPlay/HomePod device on the LAN floods thousands of entries per hour). The
/// ring keeps their INFO-and-up; stderr under `RUST_LOG` is unaffected. Prefix-matched on module
/// path boundaries.
const NOISY_DEBUG_TARGETS: &[&str] = &["mdns_sd"];
fn is_noisy_debug(target: &str) -> bool {
NOISY_DEBUG_TARGETS.iter().any(|t| {
target
.strip_prefix(t)
.is_some_and(|rest| rest.is_empty() || rest.starts_with("::"))
})
}
/// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a /// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a
/// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at /// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at
/// `info` (remote debugging must not require a restart with a different env). /// `info` (remote debugging must not require a restart with a different env).
@@ -132,7 +151,15 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
event: &tracing::Event<'_>, event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>, _ctx: tracing_subscriber::layer::Context<'_, S>,
) { ) {
let meta = event.metadata(); // Events from `log`-crate dependencies arrive through the tracing-log bridge under the
// shim target "log"; normalize back to the record's real module path so the console's
// target column and the noise gate below see `mdns_sd::…`.
use tracing_log::NormalizeEvent;
let normalized = event.normalized_metadata();
let meta = normalized.as_ref().unwrap_or_else(|| event.metadata());
if *meta.level() > tracing::Level::INFO && is_noisy_debug(meta.target()) {
return;
}
let mut fields = FieldFmt::default(); let mut fields = FieldFmt::default();
event.record(&mut fields); event.record(&mut fields);
ring().push(meta.level(), meta.target(), fields.finish()); ring().push(meta.level(), meta.target(), fields.finish());
@@ -152,7 +179,9 @@ impl tracing::field::Visit for FieldFmt {
use std::fmt::Write; use std::fmt::Write;
if field.name() == "message" { if field.name() == "message" {
let _ = write!(self.msg, "{value:?}"); let _ = write!(self.msg, "{value:?}");
} else { } else if !field.name().starts_with("log.") {
// `log.target`/`log.file`/… are tracing-log bridge bookkeeping (already surfaced via
// the normalized target), same suppression as the stderr fmt layer.
let _ = write!(self.fields, " {}={:?}", field.name(), value); let _ = write!(self.fields, " {}={:?}", field.name(), value);
} }
} }
@@ -161,7 +190,7 @@ impl tracing::field::Visit for FieldFmt {
use std::fmt::Write; use std::fmt::Write;
if field.name() == "message" { if field.name() == "message" {
self.msg.push_str(value); self.msg.push_str(value);
} else { } else if !field.name().starts_with("log.") {
let _ = write!(self.fields, " {}={value}", field.name()); let _ = write!(self.fields, " {}={value}", field.name());
} }
} }
@@ -236,20 +265,24 @@ mod tests {
assert_eq!(head.entries.first().map(|e| e.seq), Some(page.next + 1)); assert_eq!(head.entries.first().map(|e| e.seq), Some(page.next + 1));
} }
#[test] /// The singleton ring is process-wide — tests find its current tail first (parallel tests
fn layer_captures_events_into_the_singleton_ring() { /// may interleave, so they only assert on THEIR events appearing after it).
use tracing_subscriber::layer::SubscriberExt; fn tail_seq() -> u64 {
// The singleton ring is process-wide — find its current tail first (parallel tests may
// interleave, so only assert on OUR event appearing after it).
let mut cur = 0; let mut cur = 0;
loop { loop {
let page = ring().since(cur, MAX_PAGE); let page = ring().since(cur, MAX_PAGE);
if page.entries.is_empty() { if page.entries.is_empty() {
break; return cur;
} }
cur = page.next; cur = page.next;
} }
}
#[test]
fn layer_captures_events_into_the_singleton_ring() {
use tracing_subscriber::layer::SubscriberExt;
let cur = tail_seq();
let subscriber = tracing_subscriber::registry().with(RingLayer); let subscriber = tracing_subscriber::registry().with(RingLayer);
tracing::subscriber::with_default(subscriber, || { tracing::subscriber::with_default(subscriber, || {
@@ -272,6 +305,41 @@ mod tests {
assert!(hit.ts_ms > 0); assert!(hit.ts_ms > 0);
} }
#[test]
fn log_bridge_events_normalize_target_and_noisy_debug_is_dropped() {
use tracing_subscriber::layer::SubscriberExt;
// Route `log` records into tracing (what SubscriberInitExt::init does in main). Global,
// so tolerate a prior install; max_level explicit so debug! records reach the bridge.
let _ = tracing_log::LogTracer::init();
log::set_max_level(log::LevelFilter::Trace);
let cur = tail_seq();
let subscriber = tracing_subscriber::registry().with(RingLayer);
tracing::subscriber::with_default(subscriber, || {
log::debug!(target: "mdns_sd::service_daemon", "Invalid incoming DNS message: flood");
log::warn!(target: "mdns_sd::service_daemon", "a real mdns problem");
log::debug!(target: "mdns_sdx", "not actually mdns-sd");
});
let page = ring().since(cur, MAX_PAGE);
assert!(
!page.entries.iter().any(|e| e.msg.contains("flood")),
"noisy-target DEBUG must not reach the ring"
);
let warn = page
.entries
.iter()
.find(|e| e.msg.contains("a real mdns problem"))
.expect("noisy-target WARN kept");
// Normalized off the bridge's "log" shim, and the log.* bookkeeping fields are hidden.
assert_eq!(warn.target, "mdns_sd::service_daemon");
assert!(!warn.msg.contains("log.target"), "msg: {}", warn.msg);
// Prefix match respects module-path boundaries.
assert!(page.entries.iter().any(|e| e.target == "mdns_sdx"));
}
#[test] #[test]
fn message_truncation_keeps_char_boundary() { fn message_truncation_keeps_char_boundary() {
let f = FieldFmt { let f = FieldFmt {