Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af13f0b749 | |||
| d285d4a0b2 | |||
| 04f370999c | |||
| 2c937855b3 | |||
| 8005b11faf | |||
| 01fcb01019 | |||
| 95a08e99c3 | |||
| a3e1ea2b44 | |||
| 6686fcdded | |||
| 31c382fde0 | |||
| d707ee4d4e | |||
| e8196b33b8 | |||
| fd699b3e2c | |||
| 79dd8f58e3 | |||
| be879c946a | |||
| f3646d4e7c | |||
| 396c3453f5 | |||
| 6921e147dd |
@@ -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"
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -131,11 +131,21 @@ jobs:
|
|||||||
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
||||||
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||||
run: ../../../scripts/ci/ensure-windows-toolchain.ps1
|
run: ../../../scripts/ci/ensure-windows-toolchain.ps1
|
||||||
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay + gamepad drivers)
|
||||||
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
|
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
|
||||||
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve
|
# pf-vdisplay (the real IddCx driver) + pf-umdf-util (the safe UMDF primitive layer) + the two
|
||||||
# against IddCxStub end-to-end (M1 step 2 gate).
|
# gamepad drivers. pf-vdisplay linking proves the IddCx call sites resolve against IddCxStub
|
||||||
|
# end-to-end (M1 step 2 gate); the gamepad drivers prove pf-umdf-util's WDF dispatch links.
|
||||||
run: cargo build -v
|
run: cargo build -v
|
||||||
|
- name: cargo clippy the shipped drivers (-D warnings — enforces the unsafe-audit gates)
|
||||||
|
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
|
||||||
|
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
|
||||||
|
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||||
|
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||||
|
# toolchain-only probe crate and is excluded.)
|
||||||
|
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||||
|
- name: cargo fmt --check the safe-layer + gamepad drivers
|
||||||
|
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
|
||||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||||
run: |
|
run: |
|
||||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
||||||
#
|
#
|
||||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||||
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
||||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||||
|
# AMD/Intel-only boxes before main).
|
||||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
||||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||||
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||||
@@ -37,6 +38,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'crates/punktfunk-host/**'
|
- 'crates/punktfunk-host/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'crates/punktfunk-tray/**'
|
||||||
- 'packaging/windows/**'
|
- 'packaging/windows/**'
|
||||||
- 'scripts/windows/**'
|
- 'scripts/windows/**'
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
@@ -109,21 +111,22 @@ jobs:
|
|||||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
Write-Output "host version $v"
|
Write-Output "host version $v"
|
||||||
|
|
||||||
- name: Generate NVENC import lib
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
|
||||||
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
|
||||||
|
|
||||||
- name: Build (release, nvenc + amf-qsv)
|
- name: Build (release, nvenc + amf-qsv)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
||||||
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
|
||||||
|
|
||||||
- name: Clippy (host, Windows)
|
- name: Build (release, status tray)
|
||||||
|
shell: pwsh
|
||||||
|
# The per-user notification-area companion the installer bundles (punktfunk-tray.exe).
|
||||||
|
run: cargo build --release -p punktfunk-tray
|
||||||
|
|
||||||
|
- name: Clippy (host + tray, Windows)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||||
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings
|
run: |
|
||||||
|
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||||
|
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||||
|
|
||||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
@@ -100,16 +100,39 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
||||||
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
|
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
|
||||||
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via
|
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via
|
||||||
`punktfunk-host.exe driver install --gamepad`.
|
`punktfunk-host.exe driver install --gamepad`. The gamepad drivers' **business logic is 100 % safe
|
||||||
|
Rust**: every raw shared-memory / sealed-channel / WDF-request operation lives behind
|
||||||
|
`pf-umdf-util` (the audited unsafe layer — `section::MappedView` checked accessors, the
|
||||||
|
`#![forbid(unsafe_code)]` `channel::ChannelClient` state machine, `wdf::Request` tokens), so a
|
||||||
|
memory-safety bug can only live in that one small crate. The whole drivers workspace is lint-gated
|
||||||
|
(`deny(unsafe_op_in_unsafe_fn)` + `deny(clippy::undocumented_unsafe_blocks)`) with a
|
||||||
|
`cargo clippy -D warnings` step in `windows-drivers.yml`; pf-vdisplay stays FFI-bound (D3D11/IddCx)
|
||||||
|
but every `unsafe {}` there now carries a `// SAFETY:` proof (unsafe-audited, not unsafe-free).
|
||||||
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
||||||
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
||||||
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
||||||
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
||||||
the remaining piece.)
|
the remaining piece.)
|
||||||
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
- **Windows host: implemented and shipping (all-vendor, x64-only, Windows 11 22H2+).** The OS floor
|
||||||
|
is HARD: pf-vdisplay is built against IddCx 1.10 (1.10 stub + HDR `*2` DDIs + FP16 caps, no runtime
|
||||||
|
downgrade) — on Windows 10 (incl. LTSC) / Win11 21H2 the driver installs but the device fails start
|
||||||
|
with Code 10 `STATUS_DEVICE_POWER_FAILURE` (field-reported 2026-07); the installer gates on
|
||||||
|
`MinVersion=10.0.22621`. `#[cfg(windows)]` backends
|
||||||
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
|
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
|
||||||
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
|
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
|
||||||
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC
|
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`). The host↔driver frame
|
||||||
|
ring is a **sealed channel** (proto v2, `design/idd-push-security.md`): all shared objects
|
||||||
|
UNNAMED, handles `DuplicateHandle`d into the driver's WUDFHost and delivered as values over
|
||||||
|
`IOCTL_SET_FRAME_CHANNEL` (SY+BA-only control device) — only the two endpoint processes can ever
|
||||||
|
reach a frame (DDA's isolation property in user mode; adopt-on-success handle-ownership contract,
|
||||||
|
newest-delivery-wins re-attach). *Sealed channel: CI-pending + on-glass revalidation pending.*
|
||||||
|
The **gamepad SHM channels are sealed the same way** (gamepad proto v2,
|
||||||
|
`design/gamepad-channel-sealing.md`): the pad DATA sections (`XusbShm`/`PadShm`, now with a
|
||||||
|
driver-validated `pad_index`) are unnamed + handle-duplicated into the pad WUDFHost
|
||||||
|
(`gamepad_raii.rs` `PadChannel`); since the HID minidrivers have no control device, the handshake
|
||||||
|
runs over a tiny named bootstrap mailbox (`Global\pf…-boot-<i>`, pid + handle value only — tampering
|
||||||
|
is DoS-bounded). *Sealed pad channel: needs both pad drivers redeployed with the host, physical-pad
|
||||||
|
validation pending.* GPU encode (NVENC
|
||||||
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
|
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
|
||||||
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
|
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
|
||||||
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
|
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
|
||||||
@@ -155,6 +178,25 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
||||||
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
||||||
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
||||||
|
- **Status tray (`crates/punktfunk-tray`, Windows + Linux).** A small per-user companion binary
|
||||||
|
showing the host service state at a glance (running / stopped / starting / degraded / failed +
|
||||||
|
streaming session in the tooltip) with one-click actions: open web console, approve-pairing
|
||||||
|
shortcut, start/stop/restart, open logs, exit. Status precedence is **service manager first**
|
||||||
|
(SCM / systemd user unit — a port-squatter can't fake Running), then the new **loopback-only
|
||||||
|
unauthenticated** `GET /api/v1/local/summary` (counts/booleans only — no PINs/fingerprints/names;
|
||||||
|
gated in `require_auth` by peer address, needed because `mgmt-token`/`cert.pem` are
|
||||||
|
SYSTEM/Admins-DACL'd on Windows so a non-elevated tray cannot bearer-auth). Windows:
|
||||||
|
`#![windows_subsystem = "windows"]` hidden-window + `Shell_NotifyIconW` (per-session `Local\`
|
||||||
|
mutex, TaskbarCreated re-add, `--quit` for the uninstaller), actions elevate per click via
|
||||||
|
`ShellExecuteW "runas"` on `punktfunk-host.exe service start|stop|restart` (new `service restart`
|
||||||
|
subcommand: stop → wait Stopped → start); installed by the Inno `trayicon` task (HKLM Run key).
|
||||||
|
Linux: ksni (SNI over zbus, `async-io`+`blocking` features), `systemctl --user` actions (no
|
||||||
|
polkit), `/etc/xdg/autostart` entry whose `--autostart` self-gates (silent exit unless
|
||||||
|
`~/.config/punktfunk` exists or the unit is enabled); deb/rpm/arch ship binary + autostart +
|
||||||
|
hicolor icons. Icons generated by `scripts/gen-tray-icons.py` (pure-stdlib; committed .ico/.png,
|
||||||
|
brand lens + status dot). *Linux live-validated on the headless KDE session (SNI registration,
|
||||||
|
stop/start transitions, menu-driven start, dbusmenu layout); Windows code MSVC-cross-type-checked
|
||||||
|
+ clippy-clean but real Windows CI build + on-glass validation pending.*
|
||||||
|
|
||||||
## What's left
|
## What's left
|
||||||
|
|
||||||
@@ -168,11 +210,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 +246,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 +398,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 (~2–4 ms
|
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||||
at high res).
|
at high res).
|
||||||
@@ -422,6 +489,7 @@ crates/punktfunk-host/
|
|||||||
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
|
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
|
||||||
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
|
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
|
||||||
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
|
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
|
||||||
|
crates/punktfunk-tray/ per-user status tray (Win32 Shell_NotifyIcon · Linux ksni/SNI); icons via scripts/gen-tray-icons.py
|
||||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||||
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||||
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
||||||
@@ -429,6 +497,7 @@ clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameCon
|
|||||||
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
||||||
clients/decky/ Steam Deck Decky plugin
|
clients/decky/ Steam Deck Decky plugin
|
||||||
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
|
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
|
||||||
|
packaging/windows/drivers/pf-umdf-util/ audited unsafe layer (safe shm + sealed-channel + WDF request primitives) — gamepad drivers' logic is 100% safe over it
|
||||||
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
|
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
|
||||||
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
||||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||||
|
|||||||
Generated
+179
-8
@@ -228,6 +228,67 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-executor"
|
||||||
|
version = "1.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||||
|
dependencies = [
|
||||||
|
"async-task",
|
||||||
|
"concurrent-queue",
|
||||||
|
"fastrand",
|
||||||
|
"futures-lite",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-io"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"parking",
|
||||||
|
"polling",
|
||||||
|
"rustix",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-lock"
|
||||||
|
version = "3.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-process"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-signal",
|
||||||
|
"async-task",
|
||||||
|
"blocking",
|
||||||
|
"cfg-if",
|
||||||
|
"event-listener",
|
||||||
|
"futures-lite",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-recursion"
|
name = "async-recursion"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -239,6 +300,30 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-signal"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||||
|
dependencies = [
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"atomic-waker",
|
||||||
|
"cfg-if",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook-registry",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-task"
|
||||||
|
version = "4.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -434,6 +519,19 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocking"
|
||||||
|
version = "1.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"async-task",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"piper",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -2002,9 +2100,26 @@ dependencies = [
|
|||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ksni"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da9eeb3f510b6148ae68f963af2c1fbb0de4d9e4e05f82813cfb319837c3ad2b"
|
||||||
|
dependencies = [
|
||||||
|
"async-executor",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-lite",
|
||||||
|
"futures-util",
|
||||||
|
"pastey",
|
||||||
|
"serde",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2136,7 +2251,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2561,6 +2676,12 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -2599,6 +2720,17 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "piper"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pipewire"
|
name = "pipewire"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
@@ -2654,6 +2786,20 @@ version = "0.3.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polling"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"hermit-abi",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polyval"
|
name = "polyval"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2729,7 +2875,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2743,7 +2889,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2765,7 +2911,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2788,7 +2934,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2818,7 +2964,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -2839,6 +2985,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 +3010,7 @@ dependencies = [
|
|||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-log",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq",
|
||||||
"usbip-sim",
|
"usbip-sim",
|
||||||
@@ -2885,7 +3033,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -2897,6 +3045,23 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "punktfunk-tray"
|
||||||
|
version = "0.6.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"ksni",
|
||||||
|
"libc",
|
||||||
|
"rustls",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"ureq",
|
||||||
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
"windows-service",
|
||||||
|
"winresource",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -5219,8 +5384,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-broadcast",
|
"async-broadcast",
|
||||||
|
"async-executor",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-process",
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
|
"async-task",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"blocking",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"event-listener",
|
"event-listener",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
|||||||
+2
-1
@@ -4,6 +4,7 @@ members = [
|
|||||||
"crates/punktfunk-core",
|
"crates/punktfunk-core",
|
||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
"crates/punktfunk-host/vendor/usbip-sim",
|
"crates/punktfunk-host/vendor/usbip-sim",
|
||||||
|
"crates/punktfunk-tray",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
@@ -16,7 +17,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.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
|||||||
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
| **Core** — `punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
|
||||||
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
|
||||||
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
| **Native protocol** — `punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
|
||||||
| **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
|
||||||
| **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,
|
||||||
@@ -82,7 +82,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
|||||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
||||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||||
| **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||||
|
|
||||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||||
@@ -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)
|
||||||
|
|||||||
+96
-1
@@ -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.6.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -578,6 +578,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/local/summary": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"host"
|
||||||
|
],
|
||||||
|
"summary": "Local status summary for the tray icon",
|
||||||
|
"description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.",
|
||||||
|
"operationId": "getLocalSummary",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Non-sensitive local host status (loopback peers only)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LocalSummary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Non-loopback peer",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/logs": {
|
"/api/v1/logs": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2083,6 +2118,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"LocalSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
|
||||||
|
"required": [
|
||||||
|
"version",
|
||||||
|
"video_streaming",
|
||||||
|
"audio_streaming",
|
||||||
|
"paired_clients",
|
||||||
|
"native_paired_clients",
|
||||||
|
"pin_pending",
|
||||||
|
"pending_approvals"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"audio_streaming": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True while the audio stream thread is running."
|
||||||
|
},
|
||||||
|
"native_paired_clients": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Number of paired native (punktfunk/1) devices.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"paired_clients": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Number of pinned (paired) GameStream client certificates.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"pending_approvals": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Native pairing knocks awaiting the operator's approval (count only).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"pin_pending": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True while a GameStream pairing handshake is parked waiting for the user's PIN."
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SessionInfo",
|
||||||
|
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Host version (mirrors `/health`)."
|
||||||
|
},
|
||||||
|
"video_streaming": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True while the video stream thread is running."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"LogEntry": {
|
"LogEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "One captured log event.",
|
"description": "One captured log event.",
|
||||||
|
|||||||
@@ -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 = {},
|
||||||
|
|||||||
@@ -37,13 +37,30 @@ def call(method, url, token=None, data=None, content_type=None, want_json=True):
|
|||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
if content_type:
|
if content_type:
|
||||||
headers["Content-Type"] = content_type
|
headers["Content-Type"] = content_type
|
||||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
# Transient-fault retries: googleapis.com occasionally drops the TLS session ("EOF
|
||||||
try:
|
# occurred in violation of protocol" — failed two release uploads on 2026-07-02) or
|
||||||
with urllib.request.urlopen(req, timeout=300) as r:
|
# answers 5xx. Retry those with backoff; 4xx raises immediately (a real API error).
|
||||||
body = r.read()
|
# The edits API is transactional until commit, so re-sending any of these is safe.
|
||||||
except urllib.error.HTTPError as e:
|
last = None
|
||||||
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
for attempt in range(4):
|
||||||
return json.loads(body) if (want_json and body) else body
|
if attempt:
|
||||||
|
delay = 3**attempt
|
||||||
|
print(f"transient Play API failure ({last}); retry {attempt}/3 in {delay}s")
|
||||||
|
time.sleep(delay)
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as r:
|
||||||
|
body = r.read()
|
||||||
|
return json.loads(body) if (want_json and body) else body
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code >= 500:
|
||||||
|
last = f"HTTP {e.code}"
|
||||||
|
continue
|
||||||
|
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
last = str(getattr(e, "reason", e))
|
||||||
|
continue
|
||||||
|
sys.exit(f"ERROR: {method} {url} still failing after retries: {last}")
|
||||||
|
|
||||||
|
|
||||||
def load_sa():
|
def load_sa():
|
||||||
|
|||||||
@@ -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 nonzero→nonzero 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;
|
||||||
|
/// nonzero→nonzero 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.5→1→2→4 s on repeated failure — and clear it
|
// recovering. Delay the next setup() — growing 0.5→1→2→4 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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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 couldn’t 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = "Couldn’t 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 = `You’re 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 Decky’s 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
@@ -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 couldn’t 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'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 />
|
||||||
|
|||||||
@@ -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." : "Couldn’t 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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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: >k::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
@@ -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: >k::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: >k::Picture,
|
picture: >k::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
@@ -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.
@@ -2,11 +2,17 @@
|
|||||||
//!
|
//!
|
||||||
//! Two planes:
|
//! Two planes:
|
||||||
//! * [`control`] — the low-frequency `DeviceIoControl` plane (add/remove a virtual monitor, pin the
|
//! * [`control`] — the low-frequency `DeviceIoControl` plane (add/remove a virtual monitor, pin the
|
||||||
//! render adapter, keepalive, info, clear-all). Owned, clean, versioned — NOT the SudoVDA ABI.
|
//! render adapter, keepalive, info, clear-all, deliver the frame channel). Owned, clean, versioned —
|
||||||
//! * [`frame`] — the IDD-push frame transport: the host creates a ring of shared keyed-mutex textures
|
//! NOT the SudoVDA ABI.
|
||||||
//! (+ a header + a frame-ready event) and the driver opens them and publishes composited frames into
|
//! * [`frame`] — the IDD-push frame transport: the host creates a ring of **unnamed** shared
|
||||||
//! them. This crate owns the [`frame::SharedHeader`] layout, the [`frame::FrameToken`] packing, the
|
//! keyed-mutex textures (+ a header + a frame-ready event), duplicates their handles into the
|
||||||
//! `Global\` object-name scheme, and the driver-status codes.
|
//! driver's WUDFHost process and delivers the handle VALUES over
|
||||||
|
//! [`control::IOCTL_SET_FRAME_CHANNEL`]; the driver publishes composited frames into them. There is
|
||||||
|
//! deliberately no object-name scheme: an unnamed object cannot be enumerated, opened by name, or
|
||||||
|
//! pre-created ("squatted") — only the two endpoint processes ever hold a handle to any frame object
|
||||||
|
//! (the sealed channel, `design/idd-push-security.md`). This crate owns the [`frame::SharedHeader`]
|
||||||
|
//! layout, the [`frame::FrameToken`] packing, the channel-delivery struct, and the driver-status
|
||||||
|
//! codes.
|
||||||
//!
|
//!
|
||||||
//! Both planes were previously hand-duplicated, byte-for-byte, across `idd_push.rs`/`frame_transport.rs`
|
//! Both planes were previously hand-duplicated, byte-for-byte, across `idd_push.rs`/`frame_transport.rs`
|
||||||
//! and `vdisplay/sudovda.rs`/`control.rs` with only "must match" comments guarding them. Defining them
|
//! and `vdisplay/sudovda.rs`/`control.rs` with only "must match" comments guarding them. Defining them
|
||||||
@@ -43,16 +49,22 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
|
|||||||
|
|
||||||
/// Bumped on any incompatible change to either plane. Exchanged via [`control::IOCTL_GET_INFO`]; host
|
/// Bumped on any incompatible change to either plane. Exchanged via [`control::IOCTL_GET_INFO`]; host
|
||||||
/// and driver assert a match at startup so a mismatched pair fails loudly instead of corrupting.
|
/// and driver assert a match at startup so a mismatched pair fails loudly instead of corrupting.
|
||||||
pub const PROTOCOL_VERSION: u32 = 1;
|
/// v2: the sealed frame channel — the frame objects are unnamed and delivered by handle duplication
|
||||||
|
/// ([`control::IOCTL_SET_FRAME_CHANNEL`]), and [`control::AddReply`] grew `wudf_pid` (the duplication
|
||||||
|
/// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is
|
||||||
|
/// incompatible by design.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 2;
|
||||||
|
|
||||||
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
||||||
pub const fn ctl_code(func: u32) -> u32 {
|
pub const fn ctl_code(func: u32) -> u32 {
|
||||||
(0x22u32 << 16) | (func << 2)
|
(0x22u32 << 16) | (func << 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive.
|
/// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive +
|
||||||
|
/// frame-channel delivery.
|
||||||
pub mod control {
|
pub mod control {
|
||||||
use super::ctl_code;
|
use super::ctl_code;
|
||||||
|
use super::frame::RING_LEN;
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
// Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering.
|
// Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering.
|
||||||
@@ -69,6 +81,10 @@ pub mod control {
|
|||||||
/// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the
|
/// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the
|
||||||
/// SudoVDA "send-and-hope-it's-ignored" hack.
|
/// SudoVDA "send-and-hope-it's-ignored" hack.
|
||||||
pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905);
|
pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905);
|
||||||
|
/// Deliver a monitor's IDD-push frame channel: the handle VALUES of the unnamed shared objects the
|
||||||
|
/// host duplicated into the driver's WUDFHost process. Input [`SetFrameChannelRequest`]. Sent once
|
||||||
|
/// after the ring is created and again on every mid-session ring recreate (HDR-mode flip).
|
||||||
|
pub const IOCTL_SET_FRAME_CHANNEL: u32 = ctl_code(0x906);
|
||||||
|
|
||||||
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
||||||
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
||||||
@@ -103,6 +119,11 @@ pub mod control {
|
|||||||
/// `_reserved` (offset 12): an un-upgraded driver leaves it `0`, so the host can tell its
|
/// `_reserved` (offset 12): an un-upgraded driver leaves it `0`, so the host can tell its
|
||||||
/// preference was ignored (stale driver) and log it instead of silently losing per-client config.
|
/// preference was ignored (stale driver) and log it instead of silently losing per-client config.
|
||||||
pub resolved_monitor_id: u32,
|
pub resolved_monitor_id: u32,
|
||||||
|
/// The driver's own process id (the WUDFHost hosting `pf_vdisplay`) — the target the host
|
||||||
|
/// duplicates the unnamed frame-object handles INTO (`OpenProcess(PROCESS_DUP_HANDLE)` +
|
||||||
|
/// `DuplicateHandle`, then [`IOCTL_SET_FRAME_CHANNEL`]). Reported per-ADD, not per-open, so a
|
||||||
|
/// WUDFHost restart between sessions can never leave the host duplicating into a dead process.
|
||||||
|
pub wudf_pid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `IOCTL_REMOVE` input.
|
/// `IOCTL_REMOVE` input.
|
||||||
@@ -129,6 +150,39 @@ pub mod control {
|
|||||||
pub watchdog_timeout_s: u32,
|
pub watchdog_timeout_s: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `IOCTL_SET_FRAME_CHANNEL` input — the sealed frame channel's bootstrap. Every handle field is a
|
||||||
|
/// handle VALUE already duplicated into the driver's WUDFHost process by the host; receiving it, the
|
||||||
|
/// driver OWNS those handles (it closes whatever it doesn't consume — a replaced, invalid, or
|
||||||
|
/// unmatched delivery must not leak entries in its own handle table).
|
||||||
|
///
|
||||||
|
/// Handle values are only meaningful inside the target process's handle table, so this struct is
|
||||||
|
/// harmless to any third party: reading it leaks nothing openable, and spoofing it (were the control
|
||||||
|
/// device reachable — it is ACL'd to SYSTEM + admins) could at worst feed the driver values that
|
||||||
|
/// don't resolve, a DoS of the attacker's own session. The frame objects themselves are unnamed and
|
||||||
|
/// therefore unreachable by any process that isn't one of the two endpoints.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SetFrameChannelRequest {
|
||||||
|
/// The OS target id from [`AddReply`] — which monitor this channel belongs to.
|
||||||
|
pub target_id: u32,
|
||||||
|
/// The ring generation these textures belong to (must match the shared header's generation at
|
||||||
|
/// attach time; a stale delivery is dropped by the driver — a fresh one follows every recreate).
|
||||||
|
pub generation: u32,
|
||||||
|
/// How many leading entries of `texture_handles` are valid (`1..=`[`RING_LEN`]).
|
||||||
|
pub ring_len: u32,
|
||||||
|
pub _pad: u32,
|
||||||
|
/// The shared-header file-mapping handle (the driver maps it and writes status/publish tokens).
|
||||||
|
pub header_handle: u64,
|
||||||
|
/// The frame-ready auto-reset event handle (the driver signals it after each publish).
|
||||||
|
pub event_handle: u64,
|
||||||
|
/// The ring textures' shared NT handles (opened via `ID3D11Device1::OpenSharedResource1`).
|
||||||
|
pub texture_handles: [u64; RING_LEN_USIZE],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`RING_LEN`] as a usize for the `texture_handles` array length (the wire struct sizes the array
|
||||||
|
/// at the compile-time maximum; `ring_len` says how many entries are live).
|
||||||
|
pub const RING_LEN_USIZE: usize = RING_LEN as usize;
|
||||||
|
|
||||||
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
|
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
|
||||||
// rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
|
// rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
|
||||||
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
|
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
|
||||||
@@ -142,11 +196,20 @@ pub mod control {
|
|||||||
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
||||||
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
|
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
|
||||||
|
|
||||||
assert!(size_of::<AddReply>() == 16);
|
assert!(size_of::<AddReply>() == 20);
|
||||||
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
||||||
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
||||||
assert!(offset_of!(AddReply, target_id) == 8);
|
assert!(offset_of!(AddReply, target_id) == 8);
|
||||||
assert!(offset_of!(AddReply, resolved_monitor_id) == 12);
|
assert!(offset_of!(AddReply, resolved_monitor_id) == 12);
|
||||||
|
assert!(offset_of!(AddReply, wudf_pid) == 16);
|
||||||
|
|
||||||
|
assert!(size_of::<SetFrameChannelRequest>() == 32 + 8 * RING_LEN_USIZE);
|
||||||
|
assert!(offset_of!(SetFrameChannelRequest, target_id) == 0);
|
||||||
|
assert!(offset_of!(SetFrameChannelRequest, generation) == 4);
|
||||||
|
assert!(offset_of!(SetFrameChannelRequest, ring_len) == 8);
|
||||||
|
assert!(offset_of!(SetFrameChannelRequest, header_handle) == 16);
|
||||||
|
assert!(offset_of!(SetFrameChannelRequest, event_handle) == 24);
|
||||||
|
assert!(offset_of!(SetFrameChannelRequest, texture_handles) == 32);
|
||||||
|
|
||||||
assert!(size_of::<RemoveRequest>() == 8);
|
assert!(size_of::<RemoveRequest>() == 8);
|
||||||
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||||
@@ -161,11 +224,12 @@ pub mod control {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The IDD-push frame transport: the host-created shared ring header, the publish token, the names, and
|
/// The IDD-push frame transport: the host-created shared ring header, the publish token, and the
|
||||||
/// the driver-status codes. The texture ring itself is host-created D3D11 keyed-mutex textures (opened
|
/// driver-status codes. The texture ring itself is host-created **unnamed** D3D11 keyed-mutex textures;
|
||||||
/// by name on the driver side); only the *layout/contract* lives here.
|
/// the driver reaches them (and the header + event) only through handles the host duplicated into its
|
||||||
|
/// process and delivered via [`crate::control::IOCTL_SET_FRAME_CHANNEL`] — the sealed channel. Only the
|
||||||
|
/// *layout/contract* lives here.
|
||||||
pub mod frame {
|
pub mod frame {
|
||||||
use alloc::string::String;
|
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
|
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
|
||||||
@@ -195,8 +259,10 @@ pub mod frame {
|
|||||||
pub struct SharedHeader {
|
pub struct SharedHeader {
|
||||||
pub magic: u32,
|
pub magic: u32,
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
/// Bumped by the host on a ring recreate (HDR-mode flip → new texture format/names). The driver
|
/// Bumped by the host on a ring recreate (HDR-mode flip → new texture format + a fresh
|
||||||
/// re-attaches when it changes; a publish carries it so the host rejects a stale-ring publish.
|
/// [`control::IOCTL_SET_FRAME_CHANNEL`](crate::control::IOCTL_SET_FRAME_CHANNEL) delivery). The
|
||||||
|
/// driver re-attaches when it changes; a publish carries it so the host rejects a stale-ring
|
||||||
|
/// publish.
|
||||||
pub generation: u32,
|
pub generation: u32,
|
||||||
pub ring_len: u32,
|
pub ring_len: u32,
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
@@ -245,21 +311,6 @@ pub mod frame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Global\pfvd-hdr-<target>` — the shared metadata header mapping name.
|
|
||||||
pub fn header_name(target_id: u32) -> String {
|
|
||||||
alloc::format!("Global\\pfvd-hdr-{target_id}")
|
|
||||||
}
|
|
||||||
/// `Global\pfvd-evt-<target>` — the frame-ready auto-reset event name.
|
|
||||||
pub fn event_name(target_id: u32) -> String {
|
|
||||||
alloc::format!("Global\\pfvd-evt-{target_id}")
|
|
||||||
}
|
|
||||||
/// `Global\pfvd-tex-<target>-<generation>-<slot>` — a ring texture's shared-handle name. The
|
|
||||||
/// generation in the name means a recreate's new textures never collide with the old ring's
|
|
||||||
/// not-yet-released handles.
|
|
||||||
pub fn texture_name(target_id: u32, generation: u32, slot: u32) -> String {
|
|
||||||
alloc::format!("Global\\pfvd-tex-{target_id}-{generation}-{slot}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
|
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
|
||||||
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after
|
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after
|
||||||
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too.
|
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too.
|
||||||
@@ -292,8 +343,10 @@ pub mod frame {
|
|||||||
/// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
|
/// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
|
||||||
/// asserts makes a one-sided edit a compile error.
|
/// asserts makes a one-sided edit a compile error.
|
||||||
///
|
///
|
||||||
/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can
|
/// Since v2 the channel is **sealed** (`design/gamepad-channel-sealing.md`, mirroring the frame
|
||||||
/// open it) and the driver maps it. Layout only; the section itself is host-created shared memory.
|
/// channel): the host creates the DATA section ([`XusbShm`]/[`PadShm`]) UNNAMED (SYSTEM-only DACL)
|
||||||
|
/// and duplicates its handle into the driver's WUDFHost; only the tiny [`PadBootstrap`] mailbox
|
||||||
|
/// stays named (it carries nothing exploitable). Layout only; the sections are host-created.
|
||||||
pub mod gamepad {
|
pub mod gamepad {
|
||||||
use alloc::string::String;
|
use alloc::string::String;
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
@@ -316,15 +369,68 @@ pub mod gamepad {
|
|||||||
/// The section starts zeroed, so `0` always means "no driver has attached (yet)"; a pre-health
|
/// The section starts zeroed, so `0` always means "no driver has attached (yet)"; a pre-health
|
||||||
/// driver never writes the field and reads as not-attached, which the host log line calls out
|
/// driver never writes the field and reads as not-attached, which the host log line calls out
|
||||||
/// (the remedy is the same: reinstall the drivers). Bump on a gamepad-layout change.
|
/// (the remedy is the same: reinstall the drivers). Bump on a gamepad-layout change.
|
||||||
pub const GAMEPAD_PROTO_VERSION: u32 = 1;
|
///
|
||||||
|
/// v2: the **sealed pad channel** (`design/gamepad-channel-sealing.md`) — the DATA section
|
||||||
|
/// ([`XusbShm`]/[`PadShm`]) is UNNAMED and reaches the driver only as a handle the host duplicated
|
||||||
|
/// into its WUDFHost, bootstrapped through the named [`PadBootstrap`] mailbox; the DATA section
|
||||||
|
/// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery.
|
||||||
|
/// A v1 driver opens `Global\pf…-shm-<i>` (which no longer exists) and a v1 host never creates
|
||||||
|
/// the mailbox a v2 driver polls, so a mixed pairing fails closed either way.
|
||||||
|
pub const GAMEPAD_PROTO_VERSION: u32 = 2;
|
||||||
|
|
||||||
/// `Global\pfxusb-shm-<index>` — the virtual Xbox 360 (XInput) shared section.
|
/// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a
|
||||||
pub fn xusb_shm_name(index: u8) -> String {
|
/// driver only trusts a fully-initialized mailbox.
|
||||||
alloc::format!("Global\\pfxusb-shm-{index}")
|
pub const BOOT_MAGIC: u32 = 0x5442_4650;
|
||||||
|
|
||||||
|
/// `Global\pfxusb-boot-<index>` — the virtual Xbox 360 pad's bootstrap mailbox ([`PadBootstrap`]).
|
||||||
|
pub fn xusb_boot_name(index: u8) -> String {
|
||||||
|
alloc::format!("Global\\pfxusb-boot-{index}")
|
||||||
}
|
}
|
||||||
/// `Global\pfds-shm-<index>` — the virtual DualSense / DualShock 4 shared section.
|
/// `Global\pfds-boot-<index>` — the DualSense / DualShock 4 pad's bootstrap mailbox
|
||||||
pub fn pad_shm_name(index: u8) -> String {
|
/// ([`PadBootstrap`]).
|
||||||
alloc::format!("Global\\pfds-shm-{index}")
|
pub fn pad_boot_name(index: u8) -> String {
|
||||||
|
alloc::format!("Global\\pfds-boot-{index}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-pad bootstrap mailbox (32 B, named `Global\pf…-boot-<index>`, SY+LS DACL) — the ONLY
|
||||||
|
/// named object left on the gamepad channel. It exists because the pad drivers are UMDF HID
|
||||||
|
/// minidrivers with no control device (hidclass owns the stack), so there is no IOCTL to hand the
|
||||||
|
/// driver a duplicated handle or learn its WUDFHost pid; this mailbox is the late-bound handshake:
|
||||||
|
///
|
||||||
|
/// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order);
|
||||||
|
/// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and —
|
||||||
|
/// iff `host_proto` matches its own version — publishes `driver_pid`;
|
||||||
|
/// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA
|
||||||
|
/// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
|
||||||
|
/// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates
|
||||||
|
/// the mapped section's magic + `pad_index` before use.
|
||||||
|
///
|
||||||
|
/// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a
|
||||||
|
/// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService
|
||||||
|
/// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an
|
||||||
|
/// injection (it cannot place a valid section handle in the WUDFHost, and the driver's
|
||||||
|
/// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section).
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PadBootstrap {
|
||||||
|
/// [`BOOT_MAGIC`], host-stamped last at creation.
|
||||||
|
pub magic: u32,
|
||||||
|
/// The host's [`GAMEPAD_PROTO_VERSION`]. A driver whose own version differs must NOT publish
|
||||||
|
/// its pid (fail closed) — it still writes `driver_proto` so the host can log the mismatch.
|
||||||
|
pub host_proto: u32,
|
||||||
|
/// The driver's WUDFHost process id (driver-written; `0` = no driver yet). The duplication
|
||||||
|
/// target the host verifies (`verify_is_wudfhost`) before duplicating the DATA section into it.
|
||||||
|
pub driver_pid: u32,
|
||||||
|
/// The driver's [`GAMEPAD_PROTO_VERSION`] (driver-written; diagnostics only).
|
||||||
|
pub driver_proto: u32,
|
||||||
|
/// The DATA-section handle VALUE the host duplicated into `handle_pid`'s handle table
|
||||||
|
/// (host-written; valid only inside that process).
|
||||||
|
pub data_handle: u64,
|
||||||
|
/// The pid `data_handle` was duplicated for — a driver whose pid differs ignores the delivery.
|
||||||
|
pub handle_pid: u32,
|
||||||
|
/// Bumped by the host (host-global monotonic, never 0) AFTER `data_handle`/`handle_pid` are in
|
||||||
|
/// place — the driver's new-delivery trigger.
|
||||||
|
pub handle_seq: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped
|
/// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped
|
||||||
@@ -356,7 +462,12 @@ pub mod gamepad {
|
|||||||
/// Bumped by the driver on every serviced XInput IOCTL — proves the game-visible path (it
|
/// Bumped by the driver on every serviced XInput IOCTL — proves the game-visible path (it
|
||||||
/// only advances while something polls the slot, so a static value is not an error).
|
/// only advances while something polls the slot, so a static value is not an error).
|
||||||
pub driver_heartbeat: u32,
|
pub driver_heartbeat: u32,
|
||||||
pub _reserved1: [u8; 24],
|
/// The pad index this section serves (host-stamped before the magic). The driver validates it
|
||||||
|
/// against its own `pszDeviceLocation` index when it maps the delivered handle, so a mis-routed
|
||||||
|
/// (or bootstrap-tampered) cross-pad delivery is rejected instead of silently cross-wiring two
|
||||||
|
/// pads. Carved from v1 reserved space (v2).
|
||||||
|
pub pad_index: u32,
|
||||||
|
pub _reserved1: [u8; 20],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID
|
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID
|
||||||
@@ -384,7 +495,10 @@ pub mod gamepad {
|
|||||||
/// Bumped by the driver's ~125 Hz timer each tick — a true liveness heartbeat (unlike the
|
/// Bumped by the driver's ~125 Hz timer each tick — a true liveness heartbeat (unlike the
|
||||||
/// XUSB one, this advances whenever the driver is loaded, game or not).
|
/// XUSB one, this advances whenever the driver is loaded, game or not).
|
||||||
pub driver_heartbeat: u32,
|
pub driver_heartbeat: u32,
|
||||||
pub _reserved1: [u8; 104],
|
/// The pad index this section serves (host-stamped before the magic) — see
|
||||||
|
/// [`XusbShm::pad_index`]. Carved from v1 reserved space (v2).
|
||||||
|
pub pad_index: u32,
|
||||||
|
pub _reserved1: [u8; 100],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
|
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
|
||||||
@@ -408,6 +522,7 @@ pub mod gamepad {
|
|||||||
assert!(offset_of!(XusbShm, rumble_small) == 29);
|
assert!(offset_of!(XusbShm, rumble_small) == 29);
|
||||||
assert!(offset_of!(XusbShm, driver_proto) == 32);
|
assert!(offset_of!(XusbShm, driver_proto) == 32);
|
||||||
assert!(offset_of!(XusbShm, driver_heartbeat) == 36);
|
assert!(offset_of!(XusbShm, driver_heartbeat) == 36);
|
||||||
|
assert!(offset_of!(XusbShm, pad_index) == 40);
|
||||||
|
|
||||||
assert!(size_of::<PadShm>() == 256);
|
assert!(size_of::<PadShm>() == 256);
|
||||||
assert!(offset_of!(PadShm, magic) == 0);
|
assert!(offset_of!(PadShm, magic) == 0);
|
||||||
@@ -417,6 +532,16 @@ pub mod gamepad {
|
|||||||
assert!(offset_of!(PadShm, device_type) == 140);
|
assert!(offset_of!(PadShm, device_type) == 140);
|
||||||
assert!(offset_of!(PadShm, driver_proto) == 144);
|
assert!(offset_of!(PadShm, driver_proto) == 144);
|
||||||
assert!(offset_of!(PadShm, driver_heartbeat) == 148);
|
assert!(offset_of!(PadShm, driver_heartbeat) == 148);
|
||||||
|
assert!(offset_of!(PadShm, pad_index) == 152);
|
||||||
|
|
||||||
|
assert!(size_of::<PadBootstrap>() == 32);
|
||||||
|
assert!(offset_of!(PadBootstrap, magic) == 0);
|
||||||
|
assert!(offset_of!(PadBootstrap, host_proto) == 4);
|
||||||
|
assert!(offset_of!(PadBootstrap, driver_pid) == 8);
|
||||||
|
assert!(offset_of!(PadBootstrap, driver_proto) == 12);
|
||||||
|
assert!(offset_of!(PadBootstrap, data_handle) == 16);
|
||||||
|
assert!(offset_of!(PadBootstrap, handle_pid) == 24);
|
||||||
|
assert!(offset_of!(PadBootstrap, handle_seq) == 28);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,28 +612,71 @@ mod tests {
|
|||||||
adapter_luid_high: -2,
|
adapter_luid_high: -2,
|
||||||
target_id: 262,
|
target_id: 262,
|
||||||
resolved_monitor_id: 7,
|
resolved_monitor_id: 7,
|
||||||
|
wudf_pid: 4242,
|
||||||
};
|
};
|
||||||
let rbytes = bytemuck::bytes_of(&reply);
|
let rbytes = bytemuck::bytes_of(&reply);
|
||||||
assert_eq!(rbytes.len(), 16);
|
assert_eq!(rbytes.len(), 20);
|
||||||
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
|
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
|
||||||
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
|
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
|
||||||
assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
|
assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
|
||||||
|
// The v2 duplication-target pid trails at offset 16.
|
||||||
|
assert_eq!(rbytes[16..20], 4242u32.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn names_are_stable() {
|
fn frame_channel_request_roundtrips_through_bytes() {
|
||||||
assert_eq!(frame::header_name(10), "Global\\pfvd-hdr-10");
|
let mut req = control::SetFrameChannelRequest {
|
||||||
assert_eq!(frame::event_name(10), "Global\\pfvd-evt-10");
|
target_id: 262,
|
||||||
assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5");
|
generation: 3,
|
||||||
|
ring_len: frame::RING_LEN,
|
||||||
|
_pad: 0,
|
||||||
|
header_handle: 0x0000_0000_0000_1a2c,
|
||||||
|
event_handle: 0x0000_0000_0000_1b30,
|
||||||
|
texture_handles: [0; control::RING_LEN_USIZE],
|
||||||
|
};
|
||||||
|
for (k, t) in req.texture_handles.iter_mut().enumerate() {
|
||||||
|
*t = 0x2000 + k as u64 * 4;
|
||||||
|
}
|
||||||
|
let bytes = bytemuck::bytes_of(&req);
|
||||||
|
assert_eq!(bytes.len(), 32 + 8 * control::RING_LEN_USIZE);
|
||||||
|
assert_eq!(
|
||||||
|
*bytemuck::from_bytes::<control::SetFrameChannelRequest>(bytes),
|
||||||
|
req
|
||||||
|
);
|
||||||
|
// The handle values ride at 8-byte alignment from offset 16 (header, event, then the ring).
|
||||||
|
assert_eq!(bytes[16..24], 0x1a2cu64.to_le_bytes());
|
||||||
|
assert_eq!(bytes[24..32], 0x1b30u64.to_le_bytes());
|
||||||
|
assert_eq!(bytes[32..40], 0x2000u64.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gamepad_names_and_magics_are_stable() {
|
fn gamepad_names_and_magics_are_stable() {
|
||||||
assert_eq!(gamepad::xusb_shm_name(0), "Global\\pfxusb-shm-0");
|
assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
|
||||||
assert_eq!(gamepad::pad_shm_name(2), "Global\\pfds-shm-2");
|
assert_eq!(gamepad::pad_boot_name(2), "Global\\pfds-boot-2");
|
||||||
// Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs).
|
// Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs).
|
||||||
assert_eq!(gamepad::XUSB_MAGIC, 0x5558_4650);
|
assert_eq!(gamepad::XUSB_MAGIC, 0x5558_4650);
|
||||||
assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453);
|
assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453);
|
||||||
|
// "PFBT" little-endian.
|
||||||
|
assert_eq!(gamepad::BOOT_MAGIC.to_le_bytes(), *b"PFBT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pad_bootstrap_roundtrips_through_bytes() {
|
||||||
|
let b = gamepad::PadBootstrap {
|
||||||
|
magic: gamepad::BOOT_MAGIC,
|
||||||
|
host_proto: gamepad::GAMEPAD_PROTO_VERSION,
|
||||||
|
driver_pid: 1234,
|
||||||
|
driver_proto: gamepad::GAMEPAD_PROTO_VERSION,
|
||||||
|
data_handle: 0x0000_0000_0000_2a4c,
|
||||||
|
handle_pid: 1234,
|
||||||
|
handle_seq: 7,
|
||||||
|
};
|
||||||
|
let bytes = bytemuck::bytes_of(&b);
|
||||||
|
assert_eq!(bytes.len(), 32);
|
||||||
|
assert_eq!(*bytemuck::from_bytes::<gamepad::PadBootstrap>(bytes), b);
|
||||||
|
// The handle value rides 8-aligned at offset 16; the seq trails at 28 (written LAST by the host).
|
||||||
|
assert_eq!(bytes[16..24], 0x2a4cu64.to_le_bytes());
|
||||||
|
assert_eq!(bytes[28..32], 7u32.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -521,6 +689,7 @@ mod tests {
|
|||||||
control::IOCTL_PING,
|
control::IOCTL_PING,
|
||||||
control::IOCTL_GET_INFO,
|
control::IOCTL_GET_INFO,
|
||||||
control::IOCTL_CLEAR_ALL,
|
control::IOCTL_CLEAR_ALL,
|
||||||
|
control::IOCTL_SET_FRAME_CHANNEL,
|
||||||
];
|
];
|
||||||
for (i, a) in all.iter().enumerate() {
|
for (i, a) in all.iter().enumerate() {
|
||||||
for b in &all[i + 1..] {
|
for b in &all[i + 1..] {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -226,10 +232,11 @@ pf-driver-proto = { path = "../pf-driver-proto" }
|
|||||||
bytemuck = { version = "1.19", features = ["derive"] }
|
bytemuck = { version = "1.19", features = ["derive"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# NVENC hardware encode (Windows). OFF by default: it pulls the NVENC SDK, and the host then needs
|
# NVENC hardware encode (Windows). OFF by default (it pulls the NVENC SDK crate); nothing is
|
||||||
# the NVENC entry points (NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion) at link
|
# needed at link time — the entry points are resolved at RUNTIME from the driver's
|
||||||
# time — i.e. `nvencodeapi.lib` from the NVIDIA Video Codec SDK (or an import lib generated from
|
# nvEncodeAPI64.dll (encode/windows/nvenc.rs `load_api`), so the same binary starts fine on
|
||||||
# nvEncodeAPI64.dll) on the linker path. Build the GPU host with `--features nvenc`.
|
# AMD/Intel-only boxes and falls through to AMF/QSV/software. Build the GPU host with
|
||||||
|
# `--features nvenc`.
|
||||||
nvenc = ["dep:nvidia-video-codec-sdk"]
|
nvenc = ["dep:nvidia-video-codec-sdk"]
|
||||||
# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a
|
# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a
|
||||||
# `FFMPEG_DIR` (BtbN lgpl-shared — includes `*_amf`/`*_qsv`; the GPL-only x264/x265 are never used,
|
# `FFMPEG_DIR` (BtbN lgpl-shared — includes `*_amf`/`*_qsv`; the GPL-only x264/x265 are never used,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//! Build script. The only thing it does: with the `nvenc` feature (Windows GPU host), tell the
|
//! Build script: stamps the build version. NVENC deliberately needs NOTHING here — the entry
|
||||||
//! linker to pull the NVENC import library. The NVENC entry points
|
//! points (`NvEncodeAPICreateInstance` / `NvEncodeAPIGetMaxSupportedVersion`) live in
|
||||||
//! (`NvEncodeAPICreateInstance` / `NvEncodeAPIGetMaxSupportedVersion`) live in `nvEncodeAPI64.dll`
|
//! `nvEncodeAPI64.dll`, which only exists where the NVIDIA driver is installed, so
|
||||||
//! (shipped with the NVIDIA driver), so the host links against `nvencodeapi.lib`. Point
|
//! `encode/windows/nvenc.rs` resolves them at RUNTIME (`LoadLibraryExW`). The former link-time
|
||||||
//! `PUNKTFUNK_NVENC_LIB_DIR` at a directory containing `nvencodeapi.lib` — from the NVIDIA Video
|
//! import (`cargo:rustc-link-lib=nvencodeapi`) made the Windows loader kill the all-vendor host
|
||||||
//! Codec SDK, or an import lib generated from the driver's `nvEncodeAPI64.dll`
|
//! binary on every AMD/Intel-only box before `main` ("nvencodeapi64.dll was not found").
|
||||||
//! (`lib /def:nvenc.def /machine:x64 /out:nvencodeapi.lib` with the two exports above).
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Build provenance: stamp the exact package/build version into the binary so a running host
|
// Build provenance: stamp the exact package/build version into the binary so a running host
|
||||||
// can report what it is (mgmt /health, the startup log, `--version`) and a stale/shadowed
|
// can report what it is (mgmt /health, the startup log, `--version`) and a stale/shadowed
|
||||||
@@ -18,12 +17,4 @@ fn main() {
|
|||||||
.unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()));
|
.unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()));
|
||||||
println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}");
|
println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}");
|
||||||
println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION");
|
println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION");
|
||||||
|
|
||||||
if std::env::var_os("CARGO_FEATURE_NVENC").is_some() {
|
|
||||||
if let Some(dir) = std::env::var_os("PUNKTFUNK_NVENC_LIB_DIR") {
|
|
||||||
println!("cargo:rustc-link-search=native={}", dir.to_string_lossy());
|
|
||||||
}
|
|
||||||
println!("cargo:rustc-link-lib=dylib=nvencodeapi");
|
|
||||||
println!("cargo:rerun-if-env-changed=PUNKTFUNK_NVENC_LIB_DIR");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ pub struct WinCaptureTarget {
|
|||||||
pub gdi_name: String,
|
pub gdi_name: String,
|
||||||
/// Stable SudoVDA target id — re-resolved to the current GDI name on every recovery.
|
/// Stable SudoVDA target id — re-resolved to the current GDI name on every recovery.
|
||||||
pub target_id: u32,
|
pub target_id: u32,
|
||||||
|
/// The pf-vdisplay driver's WUDFHost pid (from the ADD reply) — the process the IDD-push capturer
|
||||||
|
/// duplicates the sealed frame channel's handles INTO (`idd_push::ChannelBroker`). `0` = unknown
|
||||||
|
/// (a pre-v2 pairing can't occur — the version handshake is hard — so this only guards misuse).
|
||||||
|
pub wudf_pid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
|
/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver's WUDFHost canNOT create named
|
//! P2 direct frame push (kill DDA) — HOST side, over the **sealed channel**
|
||||||
//! kernel objects, so — exactly like the gamepad UMDF drivers (`inject/dualsense_windows.rs`) — the
|
//! (`design/idd-push-security.md`). The frame channel carries whole-desktop pixels, so its protection
|
||||||
//! HOST (privileged) CREATES the shared header + frame-ready event + ring of keyed-mutex textures
|
//! must match DDA's (where capturer and consumer are one process and there is no openable channel at
|
||||||
//! (`Global\` names, scoped `D:(A;;GA;;;SY)(A;;GA;;;LS)` to SYSTEM + the driver's LocalService host —
|
//! all): the HOST (SYSTEM) creates the shared header + frame-ready event + ring of keyed-mutex textures
|
||||||
//! see `shared_object_sa`) on the discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring
|
//! **UNNAMED** on the discrete render GPU — nothing to enumerate, open by name, or pre-create
|
||||||
//! straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook. Gated by
|
//! ("squat") — then DUPLICATES the handles into the pf-vdisplay driver's WUDFHost process
|
||||||
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
|
//! ([`ChannelBroker`]; SYSTEM can `DuplicateHandle` into the LocalService host, the reverse is
|
||||||
|
//! correctly denied, which is why the HOST is the broker) and delivers the handle VALUES over the
|
||||||
|
//! SYSTEM-only control device (`IOCTL_SET_FRAME_CHANNEL`). A handle value is meaningless outside the
|
||||||
|
//! target process's handle table, so the bootstrap's ACL is not load-bearing; the only way to reach the
|
||||||
|
//! frames is to already be one of the two endpoint processes. The driver copies frames in; we consume
|
||||||
|
//! the ring straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook.
|
||||||
|
//! Gated by `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
|
||||||
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
||||||
//! `DRV_STATUS_*` codes, the `Global\` name scheme and the publish token all come from
|
//! `DRV_STATUS_*` codes, the channel-delivery struct and the publish token all come from
|
||||||
//! [`pf_driver_proto::frame`] (which OWNS the contract, with `const` size asserts) — both sides
|
//! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so
|
||||||
//! `use` it, so drift is a compile error rather than a "must match" comment.
|
//! drift is a compile error rather than a "must match" comment.
|
||||||
|
|
||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
@@ -16,12 +22,15 @@
|
|||||||
use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget};
|
use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget};
|
||||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use pf_driver_proto::frame;
|
use pf_driver_proto::{control, frame};
|
||||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use windows::core::{w, Interface, HSTRING};
|
use windows::core::{w, Interface, PCWSTR, PWSTR};
|
||||||
use windows::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE, LUID};
|
use windows::Win32::Foundation::{
|
||||||
|
DuplicateHandle, DUPLICATE_CLOSE_SOURCE, DUPLICATE_HANDLE_OPTIONS, DUPLICATE_SAME_ACCESS,
|
||||||
|
HANDLE, INVALID_HANDLE_VALUE, LUID,
|
||||||
|
};
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
|
ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
|
||||||
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
|
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
|
||||||
@@ -42,47 +51,43 @@ use windows::Win32::System::Memory::{
|
|||||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||||
};
|
};
|
||||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
use windows::Win32::System::Threading::{
|
||||||
|
CreateEventW, GetCurrentProcess, OpenProcess, QueryFullProcessImageNameW, WaitForSingleObject,
|
||||||
|
PROCESS_DUP_HANDLE, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
|
};
|
||||||
|
|
||||||
// The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
// The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
||||||
// `DRV_STATUS_*` codes and the `Global\` name helpers — lives in `pf_driver_proto::frame`; both sides
|
// `DRV_STATUS_*` codes and the channel-delivery struct — lives in `pf_driver_proto`; both sides
|
||||||
// `use frame::*`, so a layout/name/code drift is a compile error (the proto has `const` size asserts).
|
// `use` it, so a layout/code drift is a compile error (the proto has `const` size asserts).
|
||||||
use frame::{
|
use frame::{
|
||||||
event_name, header_name, texture_name, SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED,
|
SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN,
|
||||||
DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION,
|
VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not
|
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not
|
||||||
/// part of the proto contract — it is a DXGI sharing-API arg, mirrored on the driver side).
|
/// part of the proto contract — it is a DXGI sharing-API arg, mirrored on the driver side).
|
||||||
const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
|
const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
|
||||||
|
|
||||||
|
/// Least access the driver needs on the duplicated **header section**: map it read/write (it reads the
|
||||||
|
/// layout + writes `driver_status`/`driver_render_luid`/the publish token). `SECTION_MAP_READ |
|
||||||
|
/// SECTION_MAP_WRITE` (== the driver's `FILE_MAP_READ | FILE_MAP_WRITE` map flag). Duplicating with
|
||||||
|
/// exactly this — instead of `DUPLICATE_SAME_ACCESS`, which would copy the host's full-access creator
|
||||||
|
/// handle — is the "grant least privilege" discipline for unnamed shared objects (Raymond Chen,
|
||||||
|
/// *"unnamed objects aren't safe just because they're unnamed"*): a compromised driver's handle can't
|
||||||
|
/// `WRITE_DAC`/`WRITE_OWNER`/`DELETE` the object, only map it.
|
||||||
|
const SECTION_MAP_RW: u32 = 0x0004 | 0x0002;
|
||||||
|
/// Least access the driver needs on the duplicated **frame-ready event**: it only `SetEvent`s it, which
|
||||||
|
/// requires `EVENT_MODIFY_STATE`. (The host holds `SYNCHRONIZE` on its own handle to wait.)
|
||||||
|
const EVENT_MODIFY_STATE: u32 = 0x0002;
|
||||||
|
|
||||||
/// Host-owned output-ring depth: distinct NVENC-input textures rotated per frame so the in-flight
|
/// Host-owned output-ring depth: distinct NVENC-input textures rotated per frame so the in-flight
|
||||||
/// encode of frame N and the convert/copy of frame N+1 never touch the same texture. 3 covers a
|
/// encode of frame N and the convert/copy of frame N+1 never touch the same texture. 3 covers a
|
||||||
/// pipeline depth of 2 with one slot of margin.
|
/// pipeline depth of 2 with one slot of margin.
|
||||||
const OUT_RING: usize = 3;
|
const OUT_RING: usize = 3;
|
||||||
|
|
||||||
/// Bring-up debug block (fixed name) — the host creates it; the driver writes diagnostics into it
|
/// Monotonic per-process generation stamped into the header + every publish token, so the host rejects
|
||||||
/// independent of the per-target header. NOT part of `pf_driver_proto` (a host-side bring-up channel,
|
/// a stale-ring publish and the driver detects a recreate. (With unnamed textures there is no name
|
||||||
/// not the data path); the matching `DebugBlock` lives in the OLD oracle driver's `frame_transport.rs`.
|
/// collision to avoid — the generation's remaining job is the recreate/stale-publish handshake.)
|
||||||
#[repr(C)]
|
|
||||||
struct DebugBlock {
|
|
||||||
magic: u32,
|
|
||||||
run_core_entries: u32,
|
|
||||||
resolved_target_id: u32,
|
|
||||||
header_open_attempts: u32,
|
|
||||||
last_open_error: u32,
|
|
||||||
header_opened: u32,
|
|
||||||
render_luid_low: u32,
|
|
||||||
render_luid_high: i32,
|
|
||||||
frames_acquired: u32,
|
|
||||||
_pad: u32,
|
|
||||||
}
|
|
||||||
const DBG_NAME: &str = "Global\\pfvd-dbg";
|
|
||||||
const DBG_MAGIC: u32 = 0x4742_4450;
|
|
||||||
|
|
||||||
/// Monotonic per-process generation: each capturer instance stamps its ring-texture names with a
|
|
||||||
/// fresh value so a retried/overlapping `open()` never collides with a previous attempt's not-yet-
|
|
||||||
/// released shared-handle names (`DXGI_ERROR_NAME_ALREADY_EXISTS`). The driver reads it from the header.
|
|
||||||
static IDD_GENERATION: AtomicU32 = AtomicU32::new(1);
|
static IDD_GENERATION: AtomicU32 = AtomicU32::new(1);
|
||||||
|
|
||||||
fn now_ns() -> u64 {
|
fn now_ns() -> u64 {
|
||||||
@@ -94,7 +99,7 @@ fn now_ns() -> u64 {
|
|||||||
|
|
||||||
/// RAII wrapper for a file-mapping object + its mapped view: on drop the view is `UnmapViewOfFile`'d,
|
/// RAII wrapper for a file-mapping object + its mapped view: on drop the view is `UnmapViewOfFile`'d,
|
||||||
/// THEN the [`OwnedHandle`] closes the underlying mapping object (order matters — unmap before close).
|
/// THEN the [`OwnedHandle`] closes the underlying mapping object (order matters — unmap before close).
|
||||||
/// A `header`/`dbg_block` raw pointer borrows into the view via [`ptr`](Self::ptr); the section must
|
/// A `header` raw pointer borrows into the view via [`ptr`](Self::ptr); the section must
|
||||||
/// outlive it (it's declared before it in [`IddPushCapturer`], and moving the section doesn't move the
|
/// outlive it (it's declared before it in [`IddPushCapturer`], and moving the section doesn't move the
|
||||||
/// OS mapping, so the borrowed pointer stays valid).
|
/// OS mapping, so the borrowed pointer stays valid).
|
||||||
struct MappedSection {
|
struct MappedSection {
|
||||||
@@ -122,10 +127,9 @@ impl Drop for MappedSection {
|
|||||||
struct HostSlot {
|
struct HostSlot {
|
||||||
tex: ID3D11Texture2D,
|
tex: ID3D11Texture2D,
|
||||||
mutex: IDXGIKeyedMutex,
|
mutex: IDXGIKeyedMutex,
|
||||||
/// The named shared-resource handle, held only to keep the resource alive (the driver opens it by
|
/// The UNNAMED shared-resource NT handle: keeps the resource alive for the session AND is the
|
||||||
/// NAME). An [`OwnedHandle`] so it closes on drop (was a manual `CloseHandle` in a `Drop` impl);
|
/// source the [`ChannelBroker`] duplicates into the driver's WUDFHost (the ONLY way the driver can
|
||||||
/// never read directly — its sole purpose is the RAII close.
|
/// reach this texture — there is no name to open). An [`OwnedHandle`] so it closes on drop.
|
||||||
#[allow(dead_code)]
|
|
||||||
shared: OwnedHandle,
|
shared: OwnedHandle,
|
||||||
/// SRV on the slot texture so the HDR path samples the FP16 slot DIRECTLY (no slot→scratch copy);
|
/// SRV on the slot texture so the HDR path samples the FP16 slot DIRECTLY (no slot→scratch copy);
|
||||||
/// the convert pass writes the output ring while holding the slot's keyed mutex. Unused for SDR
|
/// the convert pass writes the output ring while holding the slot's keyed mutex. Unused for SDR
|
||||||
@@ -168,28 +172,238 @@ impl Drop for KeyedMutexGuard<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Confirm the process is a genuine system WUDFHost — `%SystemRoot%\System32\WUDFHost.exe` — before a
|
||||||
|
/// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's
|
||||||
|
/// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a
|
||||||
|
/// tampered mailbox could name an arbitrary process to receive the channel, so this is the
|
||||||
|
/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL
|
||||||
|
/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid)
|
||||||
|
/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`);
|
||||||
|
/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
|
||||||
|
pub(crate) unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> Result<()> {
|
||||||
|
let mut buf = [0u16; 512];
|
||||||
|
let mut len = buf.len() as u32;
|
||||||
|
// SAFETY: `process` carries QUERY_LIMITED per the contract; `buf`/`len` are a valid out-buffer and
|
||||||
|
// its capacity, and on success `len` is updated to the count of UTF-16 units written (no NUL).
|
||||||
|
unsafe {
|
||||||
|
QueryFullProcessImageNameW(
|
||||||
|
process,
|
||||||
|
PROCESS_NAME_WIN32,
|
||||||
|
PWSTR(buf.as_mut_ptr()),
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
.with_context(|| format!("QueryFullProcessImageNameW on the {what} pid"))?;
|
||||||
|
}
|
||||||
|
let path = String::from_utf16_lossy(&buf[..len as usize]);
|
||||||
|
let got = path.to_ascii_lowercase().replace('/', "\\");
|
||||||
|
let sysroot = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
|
||||||
|
let expected = format!("{}\\system32\\wudfhost.exe", sysroot.to_ascii_lowercase());
|
||||||
|
if got != expected {
|
||||||
|
bail!(
|
||||||
|
"{what} pid {wudf_pid} is not the system WUDFHost (image={path:?}, expected \
|
||||||
|
{expected:?}) — refusing to duplicate the channel's handles into it (spoofed driver / \
|
||||||
|
wrong devnode?)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
|
||||||
|
/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its
|
||||||
|
/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device
|
||||||
|
/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the
|
||||||
|
/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made
|
||||||
|
/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost.
|
||||||
|
struct ChannelBroker {
|
||||||
|
/// `PROCESS_DUP_HANDLE` handle to the driver's WUDFHost (pid from the ADD reply;
|
||||||
|
/// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's).
|
||||||
|
process: OwnedHandle,
|
||||||
|
/// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the
|
||||||
|
/// process lifetime, so holding the bare `HANDLE` is sound.
|
||||||
|
control: HANDLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelBroker {
|
||||||
|
/// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0`
|
||||||
|
/// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device
|
||||||
|
/// restart mid-open) — either way the caller fails the capture open cleanly.
|
||||||
|
///
|
||||||
|
/// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles
|
||||||
|
/// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a
|
||||||
|
/// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a
|
||||||
|
/// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes
|
||||||
|
/// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening).
|
||||||
|
fn open(wudf_pid: u32) -> Result<Self> {
|
||||||
|
if wudf_pid == 0 {
|
||||||
|
bail!("driver reported no WUDFHost pid for the frame channel");
|
||||||
|
}
|
||||||
|
let control = crate::vdisplay::manager::control_device_handle().context(
|
||||||
|
"pf-vdisplay control device not open (monitor not created via the manager?)",
|
||||||
|
)?;
|
||||||
|
// SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and
|
||||||
|
// moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it
|
||||||
|
// for the duration of the synchronous check and forms no lasting alias.
|
||||||
|
let process = unsafe {
|
||||||
|
let h = OpenProcess(
|
||||||
|
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
|
false,
|
||||||
|
wudf_pid,
|
||||||
|
)
|
||||||
|
.context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?;
|
||||||
|
let process = OwnedHandle::from_raw_handle(h.0 as _);
|
||||||
|
verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?;
|
||||||
|
process
|
||||||
|
};
|
||||||
|
Ok(Self { process, control })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only
|
||||||
|
/// there — the value is meaningless in any other process). `access = Some(rights)` grants the
|
||||||
|
/// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]);
|
||||||
|
/// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the
|
||||||
|
/// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with
|
||||||
|
/// just `DXGI_SHARED_RESOURCE_READ|WRITE`).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `h` must be a live handle of the current process.
|
||||||
|
unsafe fn dup_into(&self, h: HANDLE, access: Option<u32>) -> Result<u64> {
|
||||||
|
let mut out = HANDLE::default();
|
||||||
|
let (desired, options) = match access {
|
||||||
|
Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)),
|
||||||
|
None => (0, DUPLICATE_SAME_ACCESS),
|
||||||
|
};
|
||||||
|
// SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target;
|
||||||
|
// `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0)
|
||||||
|
// or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both.
|
||||||
|
unsafe {
|
||||||
|
DuplicateHandle(
|
||||||
|
GetCurrentProcess(),
|
||||||
|
h,
|
||||||
|
HANDLE(self.process.as_raw_handle()),
|
||||||
|
&mut out,
|
||||||
|
desired,
|
||||||
|
false,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.context("DuplicateHandle into the driver's WUDFHost")?;
|
||||||
|
Ok(out.0 as usize as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
|
||||||
|
/// with no target closes the source handle regardless of the (ignored) result.
|
||||||
|
fn close_remote(&self, value: u64) {
|
||||||
|
if value == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS
|
||||||
|
// broker just created in that process's table (callers only pass back `dup_into` results the
|
||||||
|
// driver never received); closing it there cannot touch any other process's handles.
|
||||||
|
unsafe {
|
||||||
|
let _ = DuplicateHandle(
|
||||||
|
HANDLE(self.process.as_raw_handle()),
|
||||||
|
HANDLE(value as usize as *mut core::ffi::c_void),
|
||||||
|
HANDLE::default(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
DUPLICATE_CLOSE_SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the
|
||||||
|
/// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already
|
||||||
|
/// made is reaped remotely and an error returns (the caller fails the open / logs the recreate).
|
||||||
|
/// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the
|
||||||
|
/// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `header` and `event` must be live handles of the current process (the capturer's own section +
|
||||||
|
/// event, borrowed for this synchronous call).
|
||||||
|
unsafe fn send(
|
||||||
|
&self,
|
||||||
|
target_id: u32,
|
||||||
|
generation: u32,
|
||||||
|
header: HANDLE,
|
||||||
|
event: HANDLE,
|
||||||
|
slots: &[HostSlot],
|
||||||
|
) -> Result<()> {
|
||||||
|
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
|
||||||
|
let mut req = control::SetFrameChannelRequest {
|
||||||
|
target_id,
|
||||||
|
generation,
|
||||||
|
ring_len: slots.len() as u32,
|
||||||
|
_pad: 0,
|
||||||
|
header_handle: 0,
|
||||||
|
event_handle: 0,
|
||||||
|
texture_handles: [0; control::RING_LEN_USIZE],
|
||||||
|
};
|
||||||
|
// SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live
|
||||||
|
// `OwnedHandle` the slot keeps for exactly this purpose.
|
||||||
|
let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) };
|
||||||
|
if result.is_err() {
|
||||||
|
// The driver never adopted the delivery — reap every remote duplicate so nothing lingers.
|
||||||
|
self.close_remote(req.header_handle);
|
||||||
|
self.close_remote(req.event_handle);
|
||||||
|
for v in req.texture_handles {
|
||||||
|
self.close_remote(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL.
|
||||||
|
/// Split out so `send` can reap whatever landed in `req` when any step errors.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// As [`Self::send`].
|
||||||
|
unsafe fn duplicate_and_deliver(
|
||||||
|
&self,
|
||||||
|
req: &mut control::SetFrameChannelRequest,
|
||||||
|
header: HANDLE,
|
||||||
|
event: HANDLE,
|
||||||
|
slots: &[HostSlot],
|
||||||
|
) -> Result<()> {
|
||||||
|
// SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live
|
||||||
|
// handles of this process, and `self.control` is the manager's control handle, never closed for
|
||||||
|
// the process lifetime (`send_frame_channel`'s precondition).
|
||||||
|
unsafe {
|
||||||
|
// Least privilege per handle: the header maps read/write, the event is only signalled, and
|
||||||
|
// the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`).
|
||||||
|
req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?;
|
||||||
|
req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?;
|
||||||
|
for (k, s) in slots.iter().enumerate() {
|
||||||
|
req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?;
|
||||||
|
}
|
||||||
|
crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
|
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
|
||||||
pub struct IddPushCapturer {
|
pub struct IddPushCapturer {
|
||||||
device: ID3D11Device,
|
device: ID3D11Device,
|
||||||
context: ID3D11DeviceContext,
|
context: ID3D11DeviceContext,
|
||||||
target_id: u32,
|
target_id: u32,
|
||||||
/// Owns the shared-header file mapping + its mapped view (RAII unmap-then-close). Declared BEFORE
|
/// Owns the shared-header file mapping + its mapped view (RAII unmap-then-close). Declared BEFORE
|
||||||
/// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Never read
|
/// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Also the
|
||||||
/// directly (the `header` pointer is) — held purely so the mapping outlives the capturer.
|
/// duplication source for the driver's header handle on every [`ChannelBroker::send`].
|
||||||
#[allow(dead_code)]
|
|
||||||
section: MappedSection,
|
section: MappedSection,
|
||||||
header: *mut SharedHeader,
|
header: *mut SharedHeader,
|
||||||
event: OwnedHandle,
|
event: OwnedHandle,
|
||||||
/// Owns the bring-up debug section (mapping + view), or `None` when the debug block wasn't created.
|
/// The sealed channel's handle-duplication broker (WUDFHost process + control device); used at open
|
||||||
/// Never read directly (the `dbg_block` pointer is) — held purely for the RAII unmap/close.
|
/// and again on every ring recreate to deliver fresh duplicates.
|
||||||
#[allow(dead_code)]
|
broker: ChannelBroker,
|
||||||
dbg_section: Option<MappedSection>,
|
|
||||||
dbg_block: *mut DebugBlock,
|
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
slots: Vec<HostSlot>,
|
slots: Vec<HostSlot>,
|
||||||
/// The ring/texture generation, bumped every time the ring is recreated at a new format (the
|
/// The ring/texture generation, bumped every time the ring is recreated at a new format (the
|
||||||
/// display's HDR mode flipped). Stamped into the texture names + the header so the driver re-attaches.
|
/// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches
|
||||||
|
/// (and so stale-ring publishes are rejected).
|
||||||
generation: u32,
|
generation: u32,
|
||||||
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Only used at `open`
|
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Only used at `open`
|
||||||
/// to PROACTIVELY enable advanced color (so a 10-bit client gets HDR without a manual toggle); it
|
/// to PROACTIVELY enable advanced color (so a 10-bit client gets HDR without a manual toggle); it
|
||||||
@@ -228,25 +442,31 @@ pub struct IddPushCapturer {
|
|||||||
status_logged: bool,
|
status_logged: bool,
|
||||||
_keepalive: Box<dyn Send>,
|
_keepalive: Box<dyn Send>,
|
||||||
}
|
}
|
||||||
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader`/`*mut DebugBlock` raw
|
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
|
||||||
// pointers (and the COM interfaces). It is created, used, and dropped by a SINGLE thread — the owning
|
// COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
|
||||||
// capture/encode thread — never shared: the `ID3D11DeviceContext` is the device's IMMEDIATE context
|
// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
|
||||||
// (single-threaded by D3D11 contract) and is only ever touched from that thread, and the header/
|
// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
|
||||||
// dbg_block pointers (into mappings this struct owns) are only dereferenced there. `Send` transfers
|
// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
|
||||||
// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`.
|
// only dereferenced there. `Send` transfers ownership to one thread at a time with NO concurrent
|
||||||
|
// access; we do not (and must not) claim `Sync`.
|
||||||
unsafe impl Send for IddPushCapturer {}
|
unsafe impl Send for IddPushCapturer {}
|
||||||
|
|
||||||
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM** (the host creates+publishes the
|
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected
|
||||||
/// shared event + texture ring) and **LocalService** (the account the pf_vdisplay WUDFHost runs under,
|
/// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal
|
||||||
/// which consumes them) — `D:(A;;GA;;;SY)(A;;GA;;;LS)`, the same scoping as the gamepad section. The
|
/// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the
|
||||||
/// old SDDL granted **Everyone** (`WD`), which let any local user open the `Global\pfvd-*` objects and
|
/// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not
|
||||||
/// read captured screen frames (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29):
|
/// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer
|
||||||
/// the WUDFHost token is `S-1-5-19` (LocalService), SYSTEM integrity, zero restricted SIDs — so SY+LS
|
/// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or
|
||||||
/// suffices for the driver and excludes normal user processes. `psd` must outlive `sa`.
|
/// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically
|
||||||
|
/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost
|
||||||
|
/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box
|
||||||
|
/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable
|
||||||
|
/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. `psd` must outlive
|
||||||
|
/// `sa`. See `design/idd-push-security.md`.
|
||||||
unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
|
unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
|
||||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"),
|
w!("D:P(A;;GA;;;SY)"),
|
||||||
SDDL_REVISION_1,
|
SDDL_REVISION_1,
|
||||||
&mut psd,
|
&mut psd,
|
||||||
None,
|
None,
|
||||||
@@ -262,20 +482,18 @@ unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTO
|
|||||||
|
|
||||||
impl IddPushCapturer {
|
impl IddPushCapturer {
|
||||||
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
|
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
|
||||||
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared by the name
|
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
|
||||||
/// `pfvd-tex-<target>-<generation>-<k>` so the driver opens it; a fresh generation gives fresh names
|
/// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
|
||||||
/// (so a recreate never collides with the old ring's not-yet-released handles).
|
/// the duplicate the [`ChannelBroker`] sends after the ring is published.
|
||||||
unsafe fn create_ring_slots(
|
unsafe fn create_ring_slots(
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
target_id: u32,
|
|
||||||
generation: u32,
|
|
||||||
w: u32,
|
w: u32,
|
||||||
h: u32,
|
h: u32,
|
||||||
format: DXGI_FORMAT,
|
format: DXGI_FORMAT,
|
||||||
) -> Result<Vec<HostSlot>> {
|
) -> Result<Vec<HostSlot>> {
|
||||||
let (sa, _psd) = shared_object_sa()?;
|
let (sa, _psd) = shared_object_sa()?;
|
||||||
let mut slots = Vec::new();
|
let mut slots = Vec::new();
|
||||||
for k in 0..RING_LEN {
|
for _ in 0..RING_LEN {
|
||||||
let desc = D3D11_TEXTURE2D_DESC {
|
let desc = D3D11_TEXTURE2D_DESC {
|
||||||
Width: w,
|
Width: w,
|
||||||
Height: h,
|
Height: h,
|
||||||
@@ -304,7 +522,7 @@ impl IddPushCapturer {
|
|||||||
.CreateSharedHandle(
|
.CreateSharedHandle(
|
||||||
Some(&sa as *const SECURITY_ATTRIBUTES),
|
Some(&sa as *const SECURITY_ATTRIBUTES),
|
||||||
DXGI_SHARED_RESOURCE_RW,
|
DXGI_SHARED_RESOURCE_RW,
|
||||||
&HSTRING::from(texture_name(target_id, generation, k)),
|
PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate
|
||||||
)
|
)
|
||||||
.context("CreateSharedHandle(IDD-push ring slot)")?;
|
.context("CreateSharedHandle(IDD-push ring slot)")?;
|
||||||
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
|
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
|
||||||
@@ -381,22 +599,22 @@ impl IddPushCapturer {
|
|||||||
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
||||||
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`,
|
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`,
|
||||||
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned
|
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned
|
||||||
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names
|
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device` are live borrows that
|
||||||
// are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid
|
// outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid because its backing
|
||||||
// because its backing `_psd` is held in scope for the whole block.
|
// `_psd` is held in scope for the whole block.
|
||||||
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
|
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
|
||||||
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
|
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
|
||||||
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
|
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
|
||||||
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
|
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
|
||||||
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`. The debug
|
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`.
|
||||||
// section is the same pattern at `dbg_bytes == size_of::<DebugBlock>()`, only entered when its
|
|
||||||
// own view is non-null.
|
|
||||||
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
|
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
|
||||||
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
|
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
|
||||||
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
|
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
|
||||||
// that orders all preceding writes before the driver may observe `MAGIC`.
|
// that orders all preceding writes before the driver may observe `MAGIC`.
|
||||||
// - `header`/`dbg_block` point into the OS mappings, NOT into the `MappedSection` structs, so moving
|
// - `broker.send` requires live `header`/`event` handles of this process: both borrow the just-
|
||||||
// `section`/`dbg_section` into `me` leaves them valid (see the `MappedSection` doc comment).
|
// created owned section/event for the duration of that synchronous call.
|
||||||
|
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
||||||
|
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
||||||
unsafe {
|
unsafe {
|
||||||
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
|
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
|
||||||
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||||
@@ -428,14 +646,14 @@ impl IddPushCapturer {
|
|||||||
let (sa, _psd) = shared_object_sa()?;
|
let (sa, _psd) = shared_object_sa()?;
|
||||||
let bytes = std::mem::size_of::<SharedHeader>().max(64);
|
let bytes = std::mem::size_of::<SharedHeader>().max(64);
|
||||||
|
|
||||||
// Header.
|
// Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name).
|
||||||
let map = CreateFileMappingW(
|
let map = CreateFileMappingW(
|
||||||
INVALID_HANDLE_VALUE,
|
INVALID_HANDLE_VALUE,
|
||||||
Some(&sa),
|
Some(&sa),
|
||||||
PAGE_READWRITE,
|
PAGE_READWRITE,
|
||||||
0,
|
0,
|
||||||
bytes as u32,
|
bytes as u32,
|
||||||
&HSTRING::from(header_name(target.target_id)),
|
PCWSTR::null(),
|
||||||
)
|
)
|
||||||
.context("CreateFileMapping(IDD-push header)")?;
|
.context("CreateFileMapping(IDD-push header)")?;
|
||||||
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
|
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
|
||||||
@@ -463,69 +681,45 @@ impl IddPushCapturer {
|
|||||||
// reads this into its `ring_format` and drops any surface that doesn't match.
|
// reads this into its `ring_format` and drops any surface that doesn't match.
|
||||||
(*header).dxgi_format = ring_fmt.0 as u32;
|
(*header).dxgi_format = ring_fmt.0 as u32;
|
||||||
|
|
||||||
// Frame-ready event (auto-reset).
|
// Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
|
||||||
let event = CreateEventW(
|
let event = CreateEventW(Some(&sa), false, false, PCWSTR::null())
|
||||||
Some(&sa),
|
.context("CreateEvent(IDD-push)")?;
|
||||||
false,
|
|
||||||
false,
|
|
||||||
&HSTRING::from(event_name(target.target_id)),
|
|
||||||
)
|
|
||||||
.context("CreateEvent(IDD-push)")?;
|
|
||||||
let event = OwnedHandle::from_raw_handle(event.0 as _);
|
let event = OwnedHandle::from_raw_handle(event.0 as _);
|
||||||
|
|
||||||
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
|
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
|
||||||
let slots =
|
let slots = Self::create_ring_slots(&device, w, h, ring_fmt)?;
|
||||||
Self::create_ring_slots(&device, target.target_id, generation, w, h, ring_fmt)?;
|
|
||||||
|
|
||||||
// Bring-up debug block (fixed name) — the driver writes diagnostics here. Best-effort.
|
// Publish: magic LAST (Release) — the ring must be fully initialized before the driver
|
||||||
let dbg_bytes = std::mem::size_of::<DebugBlock>();
|
// (which receives the channel strictly afterwards) can observe MAGIC.
|
||||||
let (dbg_section, dbg_block) = match CreateFileMappingW(
|
|
||||||
INVALID_HANDLE_VALUE,
|
|
||||||
Some(&sa),
|
|
||||||
PAGE_READWRITE,
|
|
||||||
0,
|
|
||||||
dbg_bytes as u32,
|
|
||||||
&HSTRING::from(DBG_NAME),
|
|
||||||
) {
|
|
||||||
Ok(dm) => {
|
|
||||||
// Own the mapping handle so it (and its view) free via `MappedSection` RAII.
|
|
||||||
let dm = OwnedHandle::from_raw_handle(dm.0 as _);
|
|
||||||
let dv = MapViewOfFile(
|
|
||||||
HANDLE(dm.as_raw_handle()),
|
|
||||||
FILE_MAP_ALL_ACCESS,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
dbg_bytes,
|
|
||||||
);
|
|
||||||
if dv.Value.is_null() {
|
|
||||||
(None, std::ptr::null_mut()) // `dm` drops → mapping closed
|
|
||||||
} else {
|
|
||||||
let section = MappedSection {
|
|
||||||
handle: dm,
|
|
||||||
view: dv,
|
|
||||||
};
|
|
||||||
let p = section.ptr::<DebugBlock>();
|
|
||||||
std::ptr::write_bytes(p.cast::<u8>(), 0, dbg_bytes);
|
|
||||||
(*p).magic = DBG_MAGIC;
|
|
||||||
(Some(section), p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => (None, std::ptr::null_mut()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Publish: magic LAST (Release) — signals the driver the ring is ready to open.
|
|
||||||
std::sync::atomic::fence(Ordering::Release);
|
std::sync::atomic::fence(Ordering::Release);
|
||||||
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
|
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
|
||||||
.store(MAGIC, Ordering::Release);
|
.store(MAGIC, Ordering::Release);
|
||||||
|
|
||||||
|
// Deliver the sealed channel: duplicate header + event + every slot texture into the
|
||||||
|
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
|
||||||
|
// broker reaps its remote duplicates on failure), and a failure fails the open — without
|
||||||
|
// the delivery the driver can never attach.
|
||||||
|
let broker = ChannelBroker::open(target.wudf_pid)?;
|
||||||
|
broker
|
||||||
|
.send(
|
||||||
|
target.target_id,
|
||||||
|
generation,
|
||||||
|
HANDLE(section.handle.as_raw_handle()),
|
||||||
|
HANDLE(event.as_raw_handle()),
|
||||||
|
&slots,
|
||||||
|
)
|
||||||
|
.context("deliver IDD-push frame channel to the driver")?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id = target.target_id,
|
target_id = target.target_id,
|
||||||
|
wudf_pid = target.wudf_pid,
|
||||||
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||||
mode = format!("{w}x{h}"),
|
mode = format!("{w}x{h}"),
|
||||||
display_hdr,
|
display_hdr,
|
||||||
client_10bit,
|
client_10bit,
|
||||||
ring_fp16 = display_hdr,
|
ring_fp16 = display_hdr,
|
||||||
"IDD push(host): created shared ring; waiting for the driver to attach + publish"
|
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
|
||||||
|
to attach + publish"
|
||||||
);
|
);
|
||||||
let me = Self {
|
let me = Self {
|
||||||
device,
|
device,
|
||||||
@@ -534,8 +728,7 @@ impl IddPushCapturer {
|
|||||||
section,
|
section,
|
||||||
header,
|
header,
|
||||||
event,
|
event,
|
||||||
dbg_section,
|
broker,
|
||||||
dbg_block,
|
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
slots,
|
slots,
|
||||||
@@ -659,34 +852,6 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log the driver's bring-up diagnostics (the fixed-name debug block) — independent of the
|
|
||||||
/// per-target header, so it tells us whether the swap-chain processor ran, what target_id it
|
|
||||||
/// resolved, whether the header opened (+ error), and whether frames flowed.
|
|
||||||
fn log_debug_block(&self) {
|
|
||||||
if self.dbg_block.is_null() {
|
|
||||||
tracing::warn!("IDD push DEBUG: no debug block");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: `self.dbg_block` was just checked non-null (the early return above); it points into the
|
|
||||||
// owned `dbg_section` mapping sized exactly `size_of::<DebugBlock>()` and page-aligned, so it is
|
|
||||||
// valid + aligned for `DebugBlock`. `d` is a short-lived SHARED reference used only to read the
|
|
||||||
// fields below; we never form `&mut` into this region, and the driver's cross-process writes are
|
|
||||||
// aligned `u32`s that don't tear (best-effort bring-up diagnostics).
|
|
||||||
let d = unsafe { &*self.dbg_block };
|
|
||||||
tracing::error!(
|
|
||||||
run_core_entries = d.run_core_entries,
|
|
||||||
resolved_target_id = d.resolved_target_id,
|
|
||||||
header_open_attempts = d.header_open_attempts,
|
|
||||||
last_open_error = format!("0x{:08x}", d.last_open_error),
|
|
||||||
header_opened = d.header_opened,
|
|
||||||
driver_render_luid = format!("{:08x}:{:08x}", d.render_luid_high, d.render_luid_low),
|
|
||||||
frames_acquired = d.frames_acquired,
|
|
||||||
"IDD push DEBUG: driver-reported diagnostics (run_core_entries=0 ⇒ swap-chain processor \
|
|
||||||
never ran; resolved_target_id≠ours ⇒ name mismatch; last_open_error 0x80070002 ⇒ header \
|
|
||||||
not found; frames_acquired=0 ⇒ idle display)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR
|
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR
|
||||||
/// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client
|
/// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client
|
||||||
/// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so
|
/// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so
|
||||||
@@ -712,9 +877,10 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Recreate the ring at the format for `new_display_hdr` (the user flipped "Use HDR"). Bumps the
|
/// Recreate the ring at the format for `new_display_hdr` (the user flipped "Use HDR"). Bumps the
|
||||||
/// generation so the driver re-attaches ([`is_stale`]) to the new-format textures; clears the
|
/// generation so the driver re-attaches ([`is_stale`]) to the new-format textures and DELIVERS the
|
||||||
/// header's `latest` so we don't consume a stale slot from the old ring; drops the conversion
|
/// new channel (fresh duplicates of the header + event + the new textures — every delivery is a
|
||||||
/// textures so they rebuild at the new format.
|
/// self-contained handle set the driver owns); clears the header's `latest` so we don't consume a
|
||||||
|
/// stale slot from the old ring; drops the conversion textures so they rebuild at the new format.
|
||||||
fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> Result<()> {
|
fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> Result<()> {
|
||||||
self.display_hdr = new_display_hdr;
|
self.display_hdr = new_display_hdr;
|
||||||
self.width = new_w;
|
self.width = new_w;
|
||||||
@@ -725,16 +891,8 @@ impl IddPushCapturer {
|
|||||||
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
|
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
|
||||||
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
|
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
|
||||||
// returned slot's texture + keyed mutex belongs to that same `self.device`.
|
// returned slot's texture + keyed mutex belongs to that same `self.device`.
|
||||||
let new_slots = unsafe {
|
let new_slots =
|
||||||
Self::create_ring_slots(
|
unsafe { Self::create_ring_slots(&self.device, self.width, self.height, fmt)? };
|
||||||
&self.device,
|
|
||||||
self.target_id,
|
|
||||||
new_gen,
|
|
||||||
self.width,
|
|
||||||
self.height,
|
|
||||||
fmt,
|
|
||||||
)?
|
|
||||||
};
|
|
||||||
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
|
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
|
||||||
// `SharedHeader`). The `latest`/`generation` stores go through `addr_of!`-formed field pointers (no
|
// `SharedHeader`). The `latest`/`generation` stores go through `addr_of!`-formed field pointers (no
|
||||||
// references) of correctly-aligned `u64`/`u32` fields, valid for `AtomicU64`/`AtomicU32`; the
|
// references) of correctly-aligned `u64`/`u32` fields, valid for `AtomicU64`/`AtomicU32`; the
|
||||||
@@ -759,6 +917,26 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
self.slots = new_slots; // drops the old slots → closes their shared handles + SRVs
|
self.slots = new_slots; // drops the old slots → closes their shared handles + SRVs
|
||||||
self.generation = new_gen;
|
self.generation = new_gen;
|
||||||
|
// Deliver the new generation's channel. The driver's old publisher sees the generation bump
|
||||||
|
// (`is_stale`), drops (closing its old handles), and re-attaches from this delivery. On failure
|
||||||
|
// the broker already reaped its remote duplicates; the recover-or-drop window in `try_consume`
|
||||||
|
// then ends the session cleanly (the driver can never attach to an undelivered ring).
|
||||||
|
// SAFETY: `broker.send` requires live `header`/`event` handles of this process — both borrow the
|
||||||
|
// owned `self.section.handle`/`self.event` for the duration of the synchronous call.
|
||||||
|
if let Err(e) = unsafe {
|
||||||
|
self.broker.send(
|
||||||
|
self.target_id,
|
||||||
|
new_gen,
|
||||||
|
HANDLE(self.section.handle.as_raw_handle()),
|
||||||
|
HANDLE(self.event.as_raw_handle()),
|
||||||
|
&self.slots,
|
||||||
|
)
|
||||||
|
} {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"IDD push: frame-channel re-delivery failed after ring recreate"
|
||||||
|
);
|
||||||
|
}
|
||||||
self.last_seq = 0;
|
self.last_seq = 0;
|
||||||
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
|
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
|
||||||
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
||||||
@@ -982,44 +1160,6 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Diagnostic observer (O3.1): create the IDD-push ring + debug block as the SYSTEM host (LocalSystem
|
|
||||||
/// — proper privileges, the gamepad pattern) ALONGSIDE the normal WGC path, which provides the
|
|
||||||
/// presentation trigger. Logs whether the driver's `run_core` ran and pushed frames into a
|
|
||||||
/// host-created ring — resolving the `run_core=0` ambiguity (a user-created ring may be unwritable by
|
|
||||||
/// the driver). Gated by `PUNKTFUNK_IDD_PUSH_OBSERVE`; spawns a short-lived sampling thread.
|
|
||||||
pub fn spawn_observer(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) {
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let tid = target.target_id;
|
|
||||||
tracing::info!(
|
|
||||||
target_id = tid,
|
|
||||||
"IDD push OBSERVER: creating host ring (LocalSystem) + debug block alongside WGC"
|
|
||||||
);
|
|
||||||
match IddPushCapturer::open(target, preferred, false, Box::new(())) {
|
|
||||||
Ok(mut cap) => {
|
|
||||||
let mut frames = 0u32;
|
|
||||||
for _ in 0..40 {
|
|
||||||
match cap.try_consume() {
|
|
||||||
Ok(Some(_)) => frames += 1,
|
|
||||||
Ok(None) => {}
|
|
||||||
Err(e) => tracing::warn!("IDD push OBSERVER: consume error: {e:#}"),
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(750));
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
target_id = tid,
|
|
||||||
frames_from_ring = frames,
|
|
||||||
"IDD push OBSERVER: sampling done"
|
|
||||||
);
|
|
||||||
cap.log_debug_block();
|
|
||||||
}
|
|
||||||
Err((e, _keep)) => tracing::warn!(
|
|
||||||
target_id = tid,
|
|
||||||
"IDD push OBSERVER: ring open failed: {e:#}"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`.
|
/// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`.
|
||||||
fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID {
|
fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID {
|
||||||
if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() {
|
if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() {
|
||||||
@@ -1046,7 +1186,6 @@ impl Capturer for IddPushCapturer {
|
|||||||
return Ok(f);
|
return Ok(f);
|
||||||
}
|
}
|
||||||
if Instant::now() > deadline {
|
if Instant::now() > deadline {
|
||||||
self.log_debug_block();
|
|
||||||
// SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping — the same
|
// SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping — the same
|
||||||
// best-effort diagnostic fields as `log_driver_status_once` (aligned word reads can't tear;
|
// best-effort diagnostic fields as `log_driver_status_once` (aligned word reads can't tear;
|
||||||
// no reference into the shared region is formed).
|
// no reference into the shared region is formed).
|
||||||
@@ -1093,8 +1232,10 @@ impl Capturer for IddPushCapturer {
|
|||||||
impl Drop for IddPushCapturer {
|
impl Drop for IddPushCapturer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.slots.clear();
|
self.slots.clear();
|
||||||
// The shared header + debug sections (`MappedSection`) and the frame-ready `event`
|
// The shared header section (`MappedSection`), the frame-ready `event` (`OwnedHandle`) and the
|
||||||
// (`OwnedHandle`) free themselves via RAII (each unmaps its view, then closes its handle).
|
// broker's WUDFHost process handle free themselves via RAII (unmap view, then close handle) —
|
||||||
// _keepalive drops after, REMOVEing the virtual display.
|
// nothing of this session's channel outlives the capturer on the host side; the driver's
|
||||||
|
// duplicates die with its publisher / monitor / WUDFHost (teardown invariant,
|
||||||
|
// `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ fn open_video_backend(
|
|||||||
{
|
{
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"NVENC requested/detected but this host was built without it — rebuild \
|
"NVENC requested/detected but this host was built without it — rebuild \
|
||||||
with `--features nvenc` (needs the NVENC SDK's nvencodeapi.lib at link time)"
|
with `--features nvenc`"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
//! NVENC hardware encoder (Windows, D3D11 input) — zero-copy capture→encode on the GPU.
|
//! NVENC hardware encoder (Windows, D3D11 input) — zero-copy capture→encode on the GPU.
|
||||||
//!
|
//!
|
||||||
//! Drives the raw NVENC API via `nvidia_video_codec_sdk::{sys, ENCODE_API}` (the safe `Encoder`
|
//! Drives the raw NVENC API via the `nvidia_video_codec_sdk` `sys` types and a **runtime-loaded**
|
||||||
//! wrapper is CUDA-only). Opens an encode session bound to the **same** `ID3D11Device` as the DXGI
|
//! entry table ([`EncodeApi`] — the crate's `ENCODE_API`/safe `Encoder` are deliberately unused:
|
||||||
|
//! the safe wrapper is CUDA-only, and its statically-declared entry points would put a load-time
|
||||||
|
//! `nvEncodeAPI64.dll` import on the all-vendor binary, killing it on every AMD/Intel-only box).
|
||||||
|
//! Opens an encode session bound to the **same** `ID3D11Device` as the DXGI
|
||||||
//! capturer (the device is carried on `FramePayload::D3d11`), and **encodes the capturer's texture in
|
//! capturer (the device is carried on `FramePayload::D3d11`), and **encodes the capturer's texture in
|
||||||
//! place** — it registers each input texture with NVENC once (cached by pointer) and `encode_picture`s
|
//! place** — it registers each input texture with NVENC once (cached by pointer) and `encode_picture`s
|
||||||
//! it directly, with NO per-frame `CopyResource`. (That's safe because the host encode loop is
|
//! it directly, with NO per-frame `CopyResource`. (That's safe because the host encode loop is
|
||||||
@@ -10,8 +13,10 @@
|
|||||||
//! pipelined, the capturer must hand a ring of textures.) Mirrors the Linux NVENC config: CBR +
|
//! pipelined, the capturer must hand a ring of textures.) Mirrors the Linux NVENC config: CBR +
|
||||||
//! ultra-low-latency, infinite GOP, P-frames only, forced-IDR for RFI, in-band SPS/PPS each keyframe.
|
//! ultra-low-latency, infinite GOP, P-frames only, forced-IDR for RFI, in-band SPS/PPS each keyframe.
|
||||||
//!
|
//!
|
||||||
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less, but
|
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less and
|
||||||
//! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback.
|
//! **starts driver-less** (the DLL resolves at runtime; on an AMD/Intel box [`try_api`] fails
|
||||||
|
//! cleanly and the AMF/QSV/software backends carry the session). The software encoder
|
||||||
|
//! (`super::sw`) is the fallback.
|
||||||
//!
|
//!
|
||||||
//! **Two-thread async retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, opt-in until on-glass validated —
|
//! **Two-thread async retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, opt-in until on-glass validated —
|
||||||
//! gpu-contention plan §5.B): the NVENC guide mandates that the main thread only *submit*
|
//! gpu-contention plan §5.B): the NVENC guide mandates that the main thread only *submit*
|
||||||
@@ -44,7 +49,182 @@ use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11Texture2D};
|
|||||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
||||||
|
|
||||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||||
use nvidia_video_codec_sdk::ENCODE_API as API;
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Runtime-loaded NVENC entry table.
|
||||||
|
//
|
||||||
|
// The NVENC entry points live in `nvEncodeAPI64.dll`, which exists ONLY where the NVIDIA driver
|
||||||
|
// is installed. They must be resolved at runtime (`LoadLibraryExW` + `GetProcAddress`), never as
|
||||||
|
// a link-time import: the shipped host binary compiles the `nvenc` feature in unconditionally,
|
||||||
|
// and a load-time DLL import makes the Windows loader refuse to start the process on every
|
||||||
|
// AMD/Intel-only box ("nvencodeapi64.dll was not found", before `main`) — `encode.rs` never gets
|
||||||
|
// the chance to dispatch to AMF/QSV. This is the Windows analogue of the Linux host's dlopen'd
|
||||||
|
// libcuda. Only the two real DLL exports are resolved by name; the rest of the table comes back
|
||||||
|
// through `NvEncodeAPICreateInstance`.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The `NV_ENCODE_API_FUNCTION_LIST` entries this encoder uses, unwrapped once at load so call
|
||||||
|
/// sites stay `(api().encode_picture)(…)`. Field names mirror the sdk crate's `EncodeAPI`, whose
|
||||||
|
/// lazy static must NOT be referenced — it calls the statically-declared externs, which is what
|
||||||
|
/// demanded the import lib at link time.
|
||||||
|
struct EncodeApi {
|
||||||
|
open_encode_session_ex: unsafe extern "C" fn(
|
||||||
|
*mut nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS,
|
||||||
|
*mut *mut c_void,
|
||||||
|
) -> nv::NVENCSTATUS,
|
||||||
|
initialize_encoder:
|
||||||
|
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_INITIALIZE_PARAMS) -> nv::NVENCSTATUS,
|
||||||
|
destroy_encoder: unsafe extern "C" fn(*mut c_void) -> nv::NVENCSTATUS,
|
||||||
|
get_encode_caps: unsafe extern "C" fn(
|
||||||
|
*mut c_void,
|
||||||
|
nv::GUID,
|
||||||
|
*mut nv::NV_ENC_CAPS_PARAM,
|
||||||
|
*mut core::ffi::c_int,
|
||||||
|
) -> nv::NVENCSTATUS,
|
||||||
|
get_encode_preset_config_ex: unsafe extern "C" fn(
|
||||||
|
*mut c_void,
|
||||||
|
nv::GUID,
|
||||||
|
nv::GUID,
|
||||||
|
nv::NV_ENC_TUNING_INFO,
|
||||||
|
*mut nv::NV_ENC_PRESET_CONFIG,
|
||||||
|
) -> nv::NVENCSTATUS,
|
||||||
|
create_bitstream_buffer: unsafe extern "C" fn(
|
||||||
|
*mut c_void,
|
||||||
|
*mut nv::NV_ENC_CREATE_BITSTREAM_BUFFER,
|
||||||
|
) -> nv::NVENCSTATUS,
|
||||||
|
destroy_bitstream_buffer:
|
||||||
|
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
|
||||||
|
lock_bitstream:
|
||||||
|
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_LOCK_BITSTREAM) -> nv::NVENCSTATUS,
|
||||||
|
unlock_bitstream: unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
|
||||||
|
register_resource:
|
||||||
|
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_REGISTER_RESOURCE) -> nv::NVENCSTATUS,
|
||||||
|
unregister_resource:
|
||||||
|
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_REGISTERED_PTR) -> nv::NVENCSTATUS,
|
||||||
|
map_input_resource:
|
||||||
|
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_MAP_INPUT_RESOURCE) -> nv::NVENCSTATUS,
|
||||||
|
unmap_input_resource:
|
||||||
|
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_INPUT_PTR) -> nv::NVENCSTATUS,
|
||||||
|
encode_picture:
|
||||||
|
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
|
||||||
|
register_async_event:
|
||||||
|
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
|
||||||
|
unregister_async_event:
|
||||||
|
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
|
||||||
|
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local `NVENCSTATUS` → `Result` (replaces the sdk's `result_without_string`, which lives in the
|
||||||
|
/// crate's `safe` module — code this file must not pull in, see [`EncodeApi`]). The raw status's
|
||||||
|
/// Debug repr (`NV_ENC_ERR_INVALID_PARAM`, …) is the error payload.
|
||||||
|
trait NvStatusExt {
|
||||||
|
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
|
||||||
|
}
|
||||||
|
impl NvStatusExt for nv::NVENCSTATUS {
|
||||||
|
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
|
||||||
|
match self {
|
||||||
|
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
|
||||||
|
err => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable on this machine (no
|
||||||
|
/// NVIDIA driver/DLL, or a driver older than our headers) — the entry points
|
||||||
|
/// ([`NvencD3d11Encoder::open`], [`probe_can_encode_444`]) gate on it and the AMF/QSV/software
|
||||||
|
/// backends carry on.
|
||||||
|
fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
|
||||||
|
static TABLE: std::sync::OnceLock<std::result::Result<EncodeApi, String>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
TABLE
|
||||||
|
.get_or_init(|| {
|
||||||
|
let table = load_api();
|
||||||
|
if let Err(e) = &table {
|
||||||
|
// Once per process. Only reachable when something resolved to NVENC on this box
|
||||||
|
// (backend misdetect or a forced PUNKTFUNK_ENCODER=nvenc) — say why it will fail.
|
||||||
|
tracing::warn!("NVENC API unavailable: {e}");
|
||||||
|
}
|
||||||
|
table
|
||||||
|
})
|
||||||
|
.as_ref()
|
||||||
|
.map_err(|e| e.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The loaded table, for call sites past a [`try_api`] gate — a live session (or the probe's own
|
||||||
|
/// gate) implies the load succeeded, and the table lives for the process lifetime.
|
||||||
|
fn api() -> &'static EncodeApi {
|
||||||
|
try_api().expect("NVENC call before a successful try_api() gate")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||||
|
use windows::core::{s, w};
|
||||||
|
use windows::Win32::System::LibraryLoader::{
|
||||||
|
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||||
|
};
|
||||||
|
// SAFETY: `LoadLibraryExW`/`GetProcAddress` take static NUL-terminated names; the
|
||||||
|
// System32-only search path keeps a planted DLL out of the SYSTEM-service process. The two
|
||||||
|
// transmutes cast the resolved exports to their documented prototypes (nvEncodeAPI.h), the
|
||||||
|
// same contract the C SDK's own loader applies. `NvEncodeAPIGetMaxSupportedVersion` writes
|
||||||
|
// one u32 through a live pointer; `NvEncodeAPICreateInstance` fills `list`, a stack-local
|
||||||
|
// `#[repr(C)]` function list with `version` set, only during the call. The module is never
|
||||||
|
// freed, so every extracted function pointer stays valid for the process lifetime.
|
||||||
|
unsafe {
|
||||||
|
let module = LoadLibraryExW(w!("nvEncodeAPI64.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32)
|
||||||
|
.map_err(|e| format!("nvEncodeAPI64.dll not loadable (no NVIDIA driver?): {e}"))?;
|
||||||
|
let get_version = GetProcAddress(module, s!("NvEncodeAPIGetMaxSupportedVersion"))
|
||||||
|
.ok_or("nvEncodeAPI64.dll exports no NvEncodeAPIGetMaxSupportedVersion")?;
|
||||||
|
let create_instance = GetProcAddress(module, s!("NvEncodeAPICreateInstance"))
|
||||||
|
.ok_or("nvEncodeAPI64.dll exports no NvEncodeAPICreateInstance")?;
|
||||||
|
let get_version: unsafe extern "C" fn(*mut u32) -> nv::NVENCSTATUS =
|
||||||
|
std::mem::transmute(get_version);
|
||||||
|
let create_instance: unsafe extern "C" fn(
|
||||||
|
*mut nv::NV_ENCODE_API_FUNCTION_LIST,
|
||||||
|
) -> nv::NVENCSTATUS = std::mem::transmute(create_instance);
|
||||||
|
|
||||||
|
let mut version = 0u32;
|
||||||
|
get_version(&mut version)
|
||||||
|
.nv_ok()
|
||||||
|
.map_err(|e| format!("NvEncodeAPIGetMaxSupportedVersion: {e:?}"))?;
|
||||||
|
// The sdk's assert_versions_match, minus the panic: an older driver is a clean Err.
|
||||||
|
let (major, minor) = (version >> 4, version & 0xf);
|
||||||
|
if (major, minor) < (nv::NVENCAPI_MAJOR_VERSION, nv::NVENCAPI_MINOR_VERSION) {
|
||||||
|
return Err(format!(
|
||||||
|
"driver NVENC API {major}.{minor} is older than the host's headers {}.{} — \
|
||||||
|
update the NVIDIA driver",
|
||||||
|
nv::NVENCAPI_MAJOR_VERSION,
|
||||||
|
nv::NVENCAPI_MINOR_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut list = nv::NV_ENCODE_API_FUNCTION_LIST {
|
||||||
|
version: nv::NV_ENCODE_API_FUNCTION_LIST_VER,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
create_instance(&mut list)
|
||||||
|
.nv_ok()
|
||||||
|
.map_err(|e| format!("NvEncodeAPICreateInstance: {e:?}"))?;
|
||||||
|
const MISSING: &str = "NvEncodeAPICreateInstance left an entry point unfilled";
|
||||||
|
Ok(EncodeApi {
|
||||||
|
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
|
||||||
|
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
|
||||||
|
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
|
||||||
|
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
|
||||||
|
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
|
||||||
|
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
|
||||||
|
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
|
||||||
|
lock_bitstream: list.nvEncLockBitstream.ok_or(MISSING)?,
|
||||||
|
unlock_bitstream: list.nvEncUnlockBitstream.ok_or(MISSING)?,
|
||||||
|
register_resource: list.nvEncRegisterResource.ok_or(MISSING)?,
|
||||||
|
unregister_resource: list.nvEncUnregisterResource.ok_or(MISSING)?,
|
||||||
|
map_input_resource: list.nvEncMapInputResource.ok_or(MISSING)?,
|
||||||
|
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
|
||||||
|
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
|
||||||
|
register_async_event: list.nvEncRegisterAsyncEvent.ok_or(MISSING)?,
|
||||||
|
unregister_async_event: list.nvEncUnregisterAsyncEvent.ok_or(MISSING)?,
|
||||||
|
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Output bitstream buffers = max in-flight encodes. The helper deep-pipelines (submits several frames
|
// Output bitstream buffers = max in-flight encodes. The helper deep-pipelines (submits several frames
|
||||||
// before locking the oldest) so per-frame GPU-scheduling waits OVERLAP instead of serializing under a
|
// before locking the oldest) so per-frame GPU-scheduling waits OVERLAP instead of serializing under a
|
||||||
@@ -143,7 +323,7 @@ fn retrieve_loop(
|
|||||||
outputBitstream: job.bs as *mut c_void,
|
outputBitstream: job.bs as *mut c_void,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
match (API.lock_bitstream)(enc as *mut c_void, &mut lock).result_without_string() {
|
match (api().lock_bitstream)(enc as *mut c_void, &mut lock).nv_ok() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let data = std::slice::from_raw_parts(
|
let data = std::slice::from_raw_parts(
|
||||||
lock.bitstreamBufferPtr as *const u8,
|
lock.bitstreamBufferPtr as *const u8,
|
||||||
@@ -155,7 +335,7 @@ fn retrieve_loop(
|
|||||||
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR
|
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR
|
||||||
| nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
|
| nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
|
||||||
);
|
);
|
||||||
let _ = (API.unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void);
|
let _ = (api().unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void);
|
||||||
Ok((data, keyframe))
|
Ok((data, keyframe))
|
||||||
}
|
}
|
||||||
Err(e) => Err(format!("lock_bitstream (async): {e:?}")),
|
Err(e) => Err(format!("lock_bitstream (async): {e:?}")),
|
||||||
@@ -255,6 +435,11 @@ impl NvencD3d11Encoder {
|
|||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
chroma: ChromaFormat,
|
chroma: ChromaFormat,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
|
// The runtime DLL load is the real "is NVENC possible here" gate: fail the open with a
|
||||||
|
// clear reason (backend misdetect / forced PUNKTFUNK_ENCODER=nvenc on a non-NVIDIA box)
|
||||||
|
// instead of an opaque session error on the first frame. Every later NVENC call in this
|
||||||
|
// file sits behind this gate (or the probe's), so the infallible `api()` is sound.
|
||||||
|
try_api().map_err(|e| anyhow!("NVENC unavailable: {e}"))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
encoder: ptr::null_mut(),
|
encoder: ptr::null_mut(),
|
||||||
codec,
|
codec,
|
||||||
@@ -309,11 +494,11 @@ impl NvencD3d11Encoder {
|
|||||||
// Unmap any in-flight inputs, then unregister every cached texture and destroy the bitstreams.
|
// Unmap any in-flight inputs, then unregister every cached texture and destroy the bitstreams.
|
||||||
for (_, map, _) in &self.pending {
|
for (_, map, _) in &self.pending {
|
||||||
if !map.is_null() {
|
if !map.is_null() {
|
||||||
let _ = (API.unmap_input_resource)(self.encoder, *map);
|
let _ = (api().unmap_input_resource)(self.encoder, *map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (reg, _tex) in self.regs.values() {
|
for (reg, _tex) in self.regs.values() {
|
||||||
let _ = (API.unregister_resource)(self.encoder, *reg);
|
let _ = (api().unregister_resource)(self.encoder, *reg);
|
||||||
}
|
}
|
||||||
// Async events: unregister from the session, then close the Win32 handles.
|
// Async events: unregister from the session, then close the Win32 handles.
|
||||||
for &ev in &self.events {
|
for &ev in &self.events {
|
||||||
@@ -322,14 +507,14 @@ impl NvencD3d11Encoder {
|
|||||||
completionEvent: ev as *mut c_void,
|
completionEvent: ev as *mut c_void,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let _ = (API.unregister_async_event)(self.encoder, &mut ep);
|
let _ = (api().unregister_async_event)(self.encoder, &mut ep);
|
||||||
let _ = CloseHandle(HANDLE(ev as *mut c_void));
|
let _ = CloseHandle(HANDLE(ev as *mut c_void));
|
||||||
}
|
}
|
||||||
self.events.clear();
|
self.events.clear();
|
||||||
for &bs in &self.bitstreams {
|
for &bs in &self.bitstreams {
|
||||||
let _ = (API.destroy_bitstream_buffer)(self.encoder, bs);
|
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
||||||
}
|
}
|
||||||
let _ = (API.destroy_encoder)(self.encoder);
|
let _ = (api().destroy_encoder)(self.encoder);
|
||||||
self.regs.clear(); // drops the texture clones, releasing our refs
|
self.regs.clear(); // drops the texture clones, releasing our refs
|
||||||
self.bitstreams.clear();
|
self.bitstreams.clear();
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
@@ -350,9 +535,7 @@ impl NvencD3d11Encoder {
|
|||||||
reserved: [0; 62],
|
reserved: [0; 62],
|
||||||
};
|
};
|
||||||
let mut val: i32 = 0;
|
let mut val: i32 = 0;
|
||||||
match (API.get_encode_caps)(enc, self.codec_guid, &mut param, &mut val)
|
match (api().get_encode_caps)(enc, self.codec_guid, &mut param, &mut val).nv_ok() {
|
||||||
.result_without_string()
|
|
||||||
{
|
|
||||||
Ok(()) => val,
|
Ok(()) => val,
|
||||||
Err(_) => 0,
|
Err(_) => 0,
|
||||||
}
|
}
|
||||||
@@ -374,8 +557,8 @@ impl NvencD3d11Encoder {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut enc: *mut c_void = ptr::null_mut();
|
let mut enc: *mut c_void = ptr::null_mut();
|
||||||
(API.open_encode_session_ex)(&mut params, &mut enc)
|
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
|
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
|
||||||
})?;
|
})?;
|
||||||
@@ -392,7 +575,7 @@ impl NvencD3d11Encoder {
|
|||||||
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
|
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
|
||||||
);
|
);
|
||||||
let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT);
|
let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT);
|
||||||
let _ = (API.destroy_encoder)(enc);
|
let _ = (api().destroy_encoder)(enc);
|
||||||
|
|
||||||
// Reject an over-range mode with a clear message instead of an opaque InvalidParam.
|
// Reject an over-range mode with a clear message instead of an opaque InvalidParam.
|
||||||
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
|
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
|
||||||
@@ -449,8 +632,8 @@ impl NvencD3d11Encoder {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut enc: *mut c_void = ptr::null_mut();
|
let mut enc: *mut c_void = ptr::null_mut();
|
||||||
(API.open_encode_session_ex)(&mut params, &mut enc)
|
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
||||||
|
|
||||||
// Seed the P1 + ultra-low-latency preset config.
|
// Seed the P1 + ultra-low-latency preset config.
|
||||||
@@ -462,16 +645,16 @@ impl NvencD3d11Encoder {
|
|||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
if let Err(e) = (API.get_encode_preset_config_ex)(
|
if let Err(e) = (api().get_encode_preset_config_ex)(
|
||||||
enc,
|
enc,
|
||||||
self.codec_guid,
|
self.codec_guid,
|
||||||
nv::NV_ENC_PRESET_P1_GUID,
|
nv::NV_ENC_PRESET_P1_GUID,
|
||||||
nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
|
nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
|
||||||
&mut preset,
|
&mut preset,
|
||||||
)
|
)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
{
|
{
|
||||||
let _ = (API.destroy_encoder)(enc);
|
let _ = (api().destroy_encoder)(enc);
|
||||||
return Err(anyhow!("get_encode_preset_config_ex: {e:?}"));
|
return Err(anyhow!("get_encode_preset_config_ex: {e:?}"));
|
||||||
}
|
}
|
||||||
let mut cfg = preset.presetCfg;
|
let mut cfg = preset.presetCfg;
|
||||||
@@ -613,10 +796,10 @@ impl NvencD3d11Encoder {
|
|||||||
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
||||||
init.set_splitEncodeMode(split_mode);
|
init.set_splitEncodeMode(split_mode);
|
||||||
|
|
||||||
match (API.initialize_encoder)(enc, &mut init).result_without_string() {
|
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||||
Ok(()) => Ok(enc),
|
Ok(()) => Ok(enc),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = (API.destroy_encoder)(enc);
|
let _ = (api().destroy_encoder)(enc);
|
||||||
Err(anyhow!("initialize_encoder: {e:?}"))
|
Err(anyhow!("initialize_encoder: {e:?}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -624,8 +807,8 @@ impl NvencD3d11Encoder {
|
|||||||
|
|
||||||
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
|
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
|
||||||
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
|
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
|
||||||
// SAFETY: every call below goes through a function pointer resolved once from the loaded
|
// SAFETY: every call below goes through a function pointer resolved once from the
|
||||||
// `nvidia_video_codec_sdk::ENCODE_API` (`nvEncodeAPI`) table, or through this type's own
|
// runtime-loaded [`EncodeApi`] table (`api()`, gated in `open`), or through this type's own
|
||||||
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
|
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
|
||||||
// the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid
|
// the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid
|
||||||
// open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
|
// open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
|
||||||
@@ -729,7 +912,7 @@ impl NvencD3d11Encoder {
|
|||||||
match self.try_open_session(device, mid, split_mode, use_async) {
|
match self.try_open_session(device, mid, split_mode, use_async) {
|
||||||
Ok(e) => {
|
Ok(e) => {
|
||||||
if !best.is_null() {
|
if !best.is_null() {
|
||||||
let _ = (API.destroy_encoder)(best);
|
let _ = (api().destroy_encoder)(best);
|
||||||
}
|
}
|
||||||
best = e;
|
best = e;
|
||||||
best_bps = mid;
|
best_bps = mid;
|
||||||
@@ -778,8 +961,8 @@ impl NvencD3d11Encoder {
|
|||||||
version: nv::NV_ENC_CREATE_BITSTREAM_BUFFER_VER,
|
version: nv::NV_ENC_CREATE_BITSTREAM_BUFFER_VER,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
(API.create_bitstream_buffer)(enc, &mut cb)
|
(api().create_bitstream_buffer)(enc, &mut cb)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?;
|
.map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?;
|
||||||
self.bitstreams.push(cb.bitstreamBuffer);
|
self.bitstreams.push(cb.bitstreamBuffer);
|
||||||
}
|
}
|
||||||
@@ -795,8 +978,8 @@ impl NvencD3d11Encoder {
|
|||||||
completionEvent: ev.0,
|
completionEvent: ev.0,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
(API.register_async_event)(enc, &mut ep)
|
(api().register_async_event)(enc, &mut ep)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("register_async_event: {e:?}"))?;
|
.map_err(|e| anyhow!("register_async_event: {e:?}"))?;
|
||||||
self.events.push(ev.0 as usize);
|
self.events.push(ev.0 as usize);
|
||||||
}
|
}
|
||||||
@@ -852,7 +1035,7 @@ impl NvencD3d11Encoder {
|
|||||||
// path's poll-side unmap, exactly once per mapping.
|
// path's poll-side unmap, exactly once per mapping.
|
||||||
unsafe {
|
unsafe {
|
||||||
if !map.is_null() {
|
if !map.is_null() {
|
||||||
let _ = (API.unmap_input_resource)(self.encoder, map);
|
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?;
|
let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?;
|
||||||
@@ -953,7 +1136,7 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
let slot = self.next % POOL;
|
let slot = self.next % POOL;
|
||||||
self.next += 1;
|
self.next += 1;
|
||||||
// SAFETY: every NVENC call goes through a function pointer from the loaded `ENCODE_API` table
|
// SAFETY: every NVENC call goes through a function pointer from the runtime-loaded `EncodeApi` table
|
||||||
// and takes `self.encoder`, the live session `init_session` just established (non-null on the
|
// and takes `self.encoder`, the live session `init_session` just established (non-null on the
|
||||||
// path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
|
// path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
|
||||||
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
|
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
|
||||||
@@ -986,8 +1169,8 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
bufferUsage: nv::NV_ENC_BUFFER_USAGE::NV_ENC_INPUT_IMAGE,
|
bufferUsage: nv::NV_ENC_BUFFER_USAGE::NV_ENC_INPUT_IMAGE,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
(API.register_resource)(self.encoder, &mut rr)
|
(api().register_resource)(self.encoder, &mut rr)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("register_resource: {e:?}"))?;
|
.map_err(|e| anyhow!("register_resource: {e:?}"))?;
|
||||||
self.regs
|
self.regs
|
||||||
.insert(key, (rr.registeredResource, frame.texture.clone()));
|
.insert(key, (rr.registeredResource, frame.texture.clone()));
|
||||||
@@ -999,8 +1182,8 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
registeredResource: reg,
|
registeredResource: reg,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
(API.map_input_resource)(self.encoder, &mut mp)
|
(api().map_input_resource)(self.encoder, &mut mp)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("map_input_resource: {e:?}"))?;
|
.map_err(|e| anyhow!("map_input_resource: {e:?}"))?;
|
||||||
|
|
||||||
let pts = self.frame_idx as u64;
|
let pts = self.frame_idx as u64;
|
||||||
@@ -1076,8 +1259,8 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
Codec::Av1 => {}
|
Codec::Av1 => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(API.encode_picture)(self.encoder, &mut pic)
|
(api().encode_picture)(self.encoder, &mut pic)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
||||||
self.pending
|
self.pending
|
||||||
.push_back((self.bitstreams[slot], mp.mappedResource, captured.pts_ns));
|
.push_back((self.bitstreams[slot], mp.mappedResource, captured.pts_ns));
|
||||||
@@ -1149,7 +1332,7 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
// We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's
|
// We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's
|
||||||
// frame number (the packetizer numbers frames in submit order), so the client's lost-frame
|
// frame number (the packetizer numbers frames in submit order), so the client's lost-frame
|
||||||
// range maps 1:1 onto the timestamps NVENC invalidates here.
|
// range maps 1:1 onto the timestamps NVENC invalidates here.
|
||||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the loaded `ENCODE_API` table.
|
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded `EncodeApi` table.
|
||||||
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
|
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
|
||||||
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
|
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
|
||||||
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
|
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
|
||||||
@@ -1157,8 +1340,8 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
// lifetime concern.
|
// lifetime concern.
|
||||||
unsafe {
|
unsafe {
|
||||||
for ts in first..=last {
|
for ts in first..=last {
|
||||||
if (API.invalidate_ref_frames)(self.encoder, ts as u64)
|
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
return false; // any failure → fall back to IDR
|
return false; // any failure → fall back to IDR
|
||||||
@@ -1195,7 +1378,7 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
};
|
};
|
||||||
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
|
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
|
||||||
// (`teardown` clears `pending` whenever it nulls the handle); all calls below use function
|
// (`teardown` clears `pending` whenever it nulls the handle); all calls below use function
|
||||||
// pointers from the loaded `ENCODE_API` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock`
|
// pointers from the runtime-loaded `EncodeApi` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock`
|
||||||
// (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture`
|
// (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture`
|
||||||
// targeted; `lock_bitstream` blocks until that encode finishes, so on success
|
// targeted; `lock_bitstream` blocks until that encode finishes, so on success
|
||||||
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
|
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
|
||||||
@@ -1209,8 +1392,8 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
outputBitstream: bs,
|
outputBitstream: bs,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
(API.lock_bitstream)(self.encoder, &mut lock)
|
(api().lock_bitstream)(self.encoder, &mut lock)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("lock_bitstream: {e:?}"))?;
|
.map_err(|e| anyhow!("lock_bitstream: {e:?}"))?;
|
||||||
let data = std::slice::from_raw_parts(
|
let data = std::slice::from_raw_parts(
|
||||||
lock.bitstreamBufferPtr as *const u8,
|
lock.bitstreamBufferPtr as *const u8,
|
||||||
@@ -1221,11 +1404,11 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
lock.pictureType,
|
lock.pictureType,
|
||||||
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
|
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
|
||||||
);
|
);
|
||||||
(API.unlock_bitstream)(self.encoder, bs)
|
(api().unlock_bitstream)(self.encoder, bs)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
|
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
|
||||||
if !map.is_null() {
|
if !map.is_null() {
|
||||||
let _ = (API.unmap_input_resource)(self.encoder, map);
|
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||||
}
|
}
|
||||||
Ok(Some(EncodedFrame {
|
Ok(Some(EncodedFrame {
|
||||||
data,
|
data,
|
||||||
@@ -1267,6 +1450,11 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
|||||||
if codec != Codec::H265 {
|
if codec != Codec::H265 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
|
||||||
|
// This is also the `api()` gate for every NVENC call below.
|
||||||
|
if try_api().is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/
|
// SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/
|
||||||
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
|
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
|
||||||
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
|
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
|
||||||
@@ -1321,8 +1509,8 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut enc: *mut c_void = ptr::null_mut();
|
let mut enc: *mut c_void = ptr::null_mut();
|
||||||
if (API.open_encode_session_ex)(&mut params, &mut enc)
|
if (api().open_encode_session_ex)(&mut params, &mut enc)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -1333,11 +1521,11 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
|||||||
reserved: [0; 62],
|
reserved: [0; 62],
|
||||||
};
|
};
|
||||||
let mut val: i32 = 0;
|
let mut val: i32 = 0;
|
||||||
let ok = (API.get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
|
let ok = (api().get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
|
||||||
.result_without_string()
|
.nv_ok()
|
||||||
.is_ok()
|
.is_ok()
|
||||||
&& val != 0;
|
&& val != 0;
|
||||||
let _ = (API.destroy_encoder)(enc);
|
let _ = (api().destroy_encoder)(enc);
|
||||||
ok
|
ok
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -820,8 +820,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sender_delivers_batches() {
|
fn sender_delivers_batches() {
|
||||||
let rx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
let rx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
// Generous: on a CI host saturated by parallel release builds, this thread can be
|
||||||
|
// starved for whole seconds between recv() wakeups.
|
||||||
rx_sock
|
rx_sock
|
||||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let tx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
let tx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
tx_sock.connect(rx_sock.local_addr().unwrap()).unwrap();
|
tx_sock.connect(rx_sock.local_addr().unwrap()).unwrap();
|
||||||
@@ -837,10 +839,15 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// 3 frames of 100 packets, content-tagged for verification.
|
// 3 frames of 20 packets, content-tagged for verification. The TOTAL burst must fit
|
||||||
|
// the receive socket's DEFAULT buffer even if this thread never drains concurrently
|
||||||
|
// (a starved CI runner): a 1200 B datagram costs ~2.5 KB kernel truesize, and the
|
||||||
|
// default rmem (~212 KB) holds only ~80 — a bigger burst gets silently dropped by
|
||||||
|
// the kernel and the test can never complete (the old 3×100 flaked exactly there).
|
||||||
|
const PER_FRAME: usize = 20;
|
||||||
let mut sent = Vec::new();
|
let mut sent = Vec::new();
|
||||||
for f in 0..3u8 {
|
for f in 0..3u8 {
|
||||||
let batch: PacketBatch = (0..100u8)
|
let batch: PacketBatch = (0..PER_FRAME as u8)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let mut p = vec![0u8; 1200];
|
let mut p = vec![0u8; 1200];
|
||||||
p[0] = f;
|
p[0] = f;
|
||||||
@@ -859,10 +866,10 @@ mod tests {
|
|||||||
let n = rx_sock.recv(&mut buf).expect("packet within timeout");
|
let n = rx_sock.recv(&mut buf).expect("packet within timeout");
|
||||||
assert_eq!(n, 1200);
|
assert_eq!(n, 1200);
|
||||||
let (f, i) = (buf[0] as usize, buf[1] as usize);
|
let (f, i) = (buf[0] as usize, buf[1] as usize);
|
||||||
assert_eq!(&buf[..n], &sent[f * 100 + i][..], "payload intact");
|
assert_eq!(&buf[..n], &sent[f * PER_FRAME + i][..], "payload intact");
|
||||||
got += 1;
|
got += 1;
|
||||||
}
|
}
|
||||||
assert_eq!(got, 300);
|
assert_eq!(got, 3 * PER_FRAME);
|
||||||
assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
|
assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/dualsense-driver`).
|
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-dualsense`).
|
||||||
//!
|
//!
|
||||||
//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and
|
//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and
|
||||||
//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where
|
//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where
|
||||||
//! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`,
|
//! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`,
|
||||||
//! the Windows backend talks to the UMDF driver over a **named shared-memory section**
|
//! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (256 B `PadShm`:
|
||||||
//! `Global\pfds-shm-<idx>` (256 B: magic `u32@0`, input report `@8`, output seq `u32@72`, output
|
//! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`) reached over the
|
||||||
//! report `@76`). The host creates the section (privileged → a permissive SDDL so the WUDFHost can
|
//! **sealed channel** ([`PadChannel`], `design/gamepad-channel-sealing.md`): the host duplicates the
|
||||||
//! open it); the driver maps it from its timer, feeds game `READ_REPORT`s from the input bytes, and
|
//! section handle into the driver's WUDFHost, bootstrapped via the named `Global\pfds-boot-<idx>`
|
||||||
//! publishes a game's `0x02` (rumble / lightbar / player-LEDs / adaptive triggers) into the output
|
//! mailbox. The driver feeds game `READ_REPORT`s from the input bytes and publishes a game's `0x02`
|
||||||
//! bytes. `hidclass` gates the device stack, so this user-mode IPC is the only viable channel (a
|
//! (rumble / lightbar / player-LEDs / adaptive triggers) into the output bytes. `hidclass` gates the
|
||||||
//! UMDF driver has no control device); see `windows-dualsense-scoping.md`.
|
//! device stack, so this user-mode IPC is the only viable channel (a UMDF driver has no control
|
||||||
|
//! device); see `windows-dualsense-scoping.md`.
|
||||||
//!
|
//!
|
||||||
//! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_<index>` software devnode (hardware id
|
//! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_<index>` software devnode (hardware id
|
||||||
//! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual
|
//! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual
|
||||||
@@ -20,12 +21,13 @@ use super::dualsense_proto::{
|
|||||||
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
|
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
|
||||||
DS_TOUCH_W,
|
DS_TOUCH_W,
|
||||||
};
|
};
|
||||||
|
use super::gamepad_raii::PadChannel;
|
||||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
|
use windows::core::{w, GUID, HRESULT, PCWSTR};
|
||||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||||
};
|
};
|
||||||
@@ -49,17 +51,19 @@ pub(super) const OFF_DEVTYPE: usize =
|
|||||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
|
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
|
||||||
pub(super) const OFF_DRIVER_PROTO: usize =
|
pub(super) const OFF_DRIVER_PROTO: usize =
|
||||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto);
|
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto);
|
||||||
|
pub(super) const OFF_PAD_INDEX: usize =
|
||||||
|
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
|
||||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
||||||
|
|
||||||
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
||||||
/// loads on it and the HID DualSense appears to games) plus the shared-memory section the driver maps.
|
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
|
||||||
/// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section.
|
/// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections.
|
||||||
struct DsWinPad {
|
struct DsWinPad {
|
||||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||||
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
|
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
|
||||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||||
/// The named shared section the driver maps (RAII — unmapped + closed on drop).
|
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
||||||
shm: super::gamepad_raii::Shm,
|
channel: PadChannel,
|
||||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||||
attach: super::gamepad_raii::DriverAttach,
|
attach: super::gamepad_raii::DriverAttach,
|
||||||
seq: u8,
|
seq: u8,
|
||||||
@@ -184,7 +188,7 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
|||||||
.encode_utf16()
|
.encode_utf16()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect();
|
.collect();
|
||||||
// The pad index, stamped into the device Location — the driver reads it to map `pfds-shm-<index>`
|
// The pad index, stamped into the device Location — the driver reads it to poll `pfds-boot-<index>`
|
||||||
// (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return).
|
// (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return).
|
||||||
let loc: Vec<u16> = format!("{}", p.container_index)
|
let loc: Vec<u16> = format!("{}", p.container_index)
|
||||||
.encode_utf16()
|
.encode_utf16()
|
||||||
@@ -266,17 +270,20 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DsWinPad {
|
impl DsWinPad {
|
||||||
/// Create + map the section `Global\pfds-shm-<index>`, stamp the magic, then spawn the
|
/// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
|
||||||
/// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives
|
/// the pad index + neutral report + the magic LAST, then spawn the `pf_pad_<index>` devnode (the
|
||||||
/// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
|
/// driver loads on it and receives the DATA handle over the bootstrap). The devnode lives for the
|
||||||
|
/// pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
|
||||||
fn open(index: u8) -> Result<DsWinPad> {
|
fn open(index: u8) -> Result<DsWinPad> {
|
||||||
let shm_name = pf_driver_proto::gamepad::pad_shm_name(index);
|
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||||
let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?;
|
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||||
let base = shm.base();
|
let base = channel.data_base();
|
||||||
// Stamp the neutral input report, then the magic LAST (the driver only accepts the section
|
// Stamp the pad index (the driver validates it on attach) + the neutral input report, then
|
||||||
// once magic is set). The device-type stays 0 (DualSense — the section is already zeroed).
|
// the magic LAST (the driver only accepts the section once magic is set). The device-type
|
||||||
// SAFETY: base points at SHM_SIZE writable bytes.
|
// stays 0 (DualSense — the section arrives zeroed).
|
||||||
|
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX/OFF_INPUT are in range.
|
||||||
unsafe {
|
unsafe {
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
|
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
|
||||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||||
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
||||||
@@ -286,7 +293,7 @@ impl DsWinPad {
|
|||||||
}
|
}
|
||||||
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
|
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
|
||||||
// rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense`
|
// rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense`
|
||||||
// devnode (installer / dev-box devgen).
|
// devnode (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
|
||||||
let inst = format!("pf_pad_{index}");
|
let inst = format!("pf_pad_{index}");
|
||||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||||
instance: &inst,
|
instance: &inst,
|
||||||
@@ -302,14 +309,17 @@ impl DsWinPad {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||||
|
// Bounded eager delivery so the driver holds the DATA section before hidclass asks it for
|
||||||
|
// descriptors (the driver reads `device_type` from the section to pick its HID identity).
|
||||||
|
channel.deliver_eager(Duration::from_millis(1500));
|
||||||
Ok(DsWinPad {
|
Ok(DsWinPad {
|
||||||
_sw,
|
_sw,
|
||||||
shm,
|
channel,
|
||||||
attach: super::gamepad_raii::DriverAttach::new(
|
attach: super::gamepad_raii::DriverAttach::new(
|
||||||
"pf_dualsense",
|
"pf_dualsense",
|
||||||
"pf_dualsense.inf",
|
"pf_dualsense.inf",
|
||||||
"C:\\Users\\Public\\pfds-driver.log",
|
"C:\\Users\\Public\\pfds-driver.log",
|
||||||
shm_name,
|
boot_name,
|
||||||
instance_id,
|
instance_id,
|
||||||
),
|
),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
@@ -326,30 +336,40 @@ impl DsWinPad {
|
|||||||
serialize_state(&mut r, st, self.seq, self.ts);
|
serialize_state(&mut r, st, self.seq, self.ts);
|
||||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len())
|
std::ptr::copy_nonoverlapping(
|
||||||
|
r.as_ptr(),
|
||||||
|
self.channel.data_base().add(OFF_INPUT),
|
||||||
|
r.len(),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a
|
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a
|
||||||
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
|
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
|
||||||
/// new. Also feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps
|
/// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
|
||||||
/// `driver_proto` while it has the section mapped).
|
/// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
|
||||||
fn service(&mut self, pad: u8) -> DsFeedback {
|
fn service(&mut self, pad: u8) -> DsFeedback {
|
||||||
|
self.channel.pump();
|
||||||
let mut fb = DsFeedback::default();
|
let mut fb = DsFeedback::default();
|
||||||
// SAFETY: base points at SHM_SIZE bytes.
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
let proto = unsafe {
|
let proto = unsafe {
|
||||||
std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32)
|
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||||
};
|
};
|
||||||
self.attach.observe(proto);
|
self.attach.observe(proto);
|
||||||
// SAFETY: base points at SHM_SIZE bytes.
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
let seq =
|
let seq = unsafe {
|
||||||
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
|
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
||||||
|
};
|
||||||
if seq != self.last_out_seq {
|
if seq != self.last_out_seq {
|
||||||
self.last_out_seq = seq;
|
self.last_out_seq = seq;
|
||||||
let mut out = [0u8; 64];
|
let mut out = [0u8; 64];
|
||||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
std::ptr::copy_nonoverlapping(
|
||||||
|
self.channel.data_base().add(OFF_OUTPUT),
|
||||||
|
out.as_mut_ptr(),
|
||||||
|
64,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
parse_ds_output(pad, &out, &mut fb);
|
parse_ds_output(pad, &out, &mut fb);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
//! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of
|
//! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of
|
||||||
//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the
|
//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the sealed
|
||||||
//! `Global\pfds-shm-<idx>` shared section the driver maps), same controller model ([`DsState`]); only
|
//! shared-memory channel bootstrapped via `Global\pfds-boot-<idx>`), same controller model
|
||||||
//! the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the report codec
|
//! ([`DsState`]); only the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the
|
||||||
//! ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4) into the
|
//! report codec ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4)
|
||||||
//! section so the one UMDF driver serves the DS4 descriptor / attributes / features instead of the
|
//! into the DATA section so the one UMDF driver serves the DS4 descriptor / attributes / features
|
||||||
//! DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar (0xCD `Led`); a DS4
|
//! instead of the DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar
|
||||||
//! has no adaptive triggers / player LEDs.
|
//! (0xCD `Led`); a DS4 has no adaptive triggers / player LEDs.
|
||||||
|
|
||||||
use super::dualsense_proto::DsState;
|
use super::dualsense_proto::DsState;
|
||||||
use super::dualsense_windows::{
|
use super::dualsense_windows::{
|
||||||
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT,
|
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT,
|
||||||
OFF_OUTPUT, OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE,
|
OFF_OUTPUT, OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
|
||||||
};
|
};
|
||||||
use super::dualshock4_proto::{
|
use super::dualshock4_proto::{
|
||||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
|
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
|
||||||
};
|
};
|
||||||
|
use super::gamepad_raii::PadChannel;
|
||||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use windows::core::HSTRING;
|
|
||||||
|
|
||||||
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the mapped
|
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the sealed
|
||||||
/// shared section. Dropping it removes the devnode and unmaps + closes the section.
|
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||||
struct Ds4WinPad {
|
struct Ds4WinPad {
|
||||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||||
/// The named shared section the driver maps (RAII — unmapped + closed on drop).
|
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
||||||
shm: super::gamepad_raii::Shm,
|
channel: PadChannel,
|
||||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||||
attach: super::gamepad_raii::DriverAttach,
|
attach: super::gamepad_raii::DriverAttach,
|
||||||
counter: u8,
|
counter: u8,
|
||||||
@@ -36,16 +36,19 @@ struct Ds4WinPad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Ds4WinPad {
|
impl Ds4WinPad {
|
||||||
/// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic,
|
/// Create the sealed channel, stamp `device_type = DualShock 4` + the pad index + a neutral
|
||||||
/// then spawn the `pf_ds4_<index>` devnode (the driver loads on it and maps the section).
|
/// report + the magic LAST, then spawn the `pf_ds4_<index>` devnode (the driver loads on it and
|
||||||
|
/// receives the DATA handle over the bootstrap).
|
||||||
fn open(index: u8) -> Result<Ds4WinPad> {
|
fn open(index: u8) -> Result<Ds4WinPad> {
|
||||||
let shm_name = pf_driver_proto::gamepad::pad_shm_name(index);
|
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||||
let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?;
|
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||||
let base = shm.base();
|
let base = channel.data_base();
|
||||||
// device-type FIRST (so it's visible the moment magic is), neutral report, magic LAST.
|
// device-type FIRST (so it's visible the moment magic is), pad index, neutral report,
|
||||||
// SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range.
|
// magic LAST.
|
||||||
|
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||||
unsafe {
|
unsafe {
|
||||||
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
|
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
|
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
|
||||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||||
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
||||||
@@ -68,14 +71,18 @@ impl Ds4WinPad {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||||
|
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
|
||||||
|
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for
|
||||||
|
// descriptors, or the pad would enumerate with the (default) DualSense identity.
|
||||||
|
channel.deliver_eager(Duration::from_millis(1500));
|
||||||
Ok(Ds4WinPad {
|
Ok(Ds4WinPad {
|
||||||
_sw,
|
_sw,
|
||||||
shm,
|
channel,
|
||||||
attach: super::gamepad_raii::DriverAttach::new(
|
attach: super::gamepad_raii::DriverAttach::new(
|
||||||
"pf_dualshock4",
|
"pf_dualshock4",
|
||||||
"pf_dualsense.inf", // one driver package serves both HID identities
|
"pf_dualsense.inf", // one driver package serves both HID identities
|
||||||
"C:\\Users\\Public\\pfds-driver.log",
|
"C:\\Users\\Public\\pfds-driver.log",
|
||||||
shm_name,
|
boot_name,
|
||||||
instance_id,
|
instance_id,
|
||||||
),
|
),
|
||||||
counter: 0,
|
counter: 0,
|
||||||
@@ -92,29 +99,40 @@ impl Ds4WinPad {
|
|||||||
serialize_state(&mut r, st, self.counter, self.ts);
|
serialize_state(&mut r, st, self.counter, self.ts);
|
||||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len())
|
std::ptr::copy_nonoverlapping(
|
||||||
|
r.as_ptr(),
|
||||||
|
self.channel.data_base().add(OFF_INPUT),
|
||||||
|
r.len(),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a
|
/// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a
|
||||||
/// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also
|
/// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also
|
||||||
/// feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps `driver_proto`).
|
/// ticks the sealed-channel delivery and feeds the driver-attach health watcher (the driver's
|
||||||
|
/// ~125 Hz timer stamps `driver_proto`).
|
||||||
fn service(&mut self) -> Ds4Feedback {
|
fn service(&mut self) -> Ds4Feedback {
|
||||||
|
self.channel.pump();
|
||||||
let mut fb = Ds4Feedback::default();
|
let mut fb = Ds4Feedback::default();
|
||||||
// SAFETY: base points at SHM_SIZE bytes.
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
let proto = unsafe {
|
let proto = unsafe {
|
||||||
std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32)
|
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||||
};
|
};
|
||||||
self.attach.observe(proto);
|
self.attach.observe(proto);
|
||||||
// SAFETY: base points at SHM_SIZE bytes.
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
let seq =
|
let seq = unsafe {
|
||||||
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
|
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
||||||
|
};
|
||||||
if seq != self.last_out_seq {
|
if seq != self.last_out_seq {
|
||||||
self.last_out_seq = seq;
|
self.last_out_seq = seq;
|
||||||
let mut out = [0u8; 64];
|
let mut out = [0u8; 64];
|
||||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
std::ptr::copy_nonoverlapping(
|
||||||
|
self.channel.data_base().add(OFF_OUTPUT),
|
||||||
|
out.as_mut_ptr(),
|
||||||
|
64,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
parse_ds4_output(&out, &mut fb);
|
parse_ds4_output(&out, &mut fb);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
//! Per-pad Windows resource RAII for the gamepad backends (DualSense / DualShock 4 / XUSB).
|
//! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 /
|
||||||
|
//! XUSB backends).
|
||||||
//!
|
//!
|
||||||
//! Each virtual pad owns two OS resources: the named shared-memory section (+ its mapped view) the
|
//! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_dualsense`/`pf_xusb`
|
||||||
//! `pf_dualsense`/`pf_xusb` driver reads, and the `SwDeviceCreate`'d software devnode the driver loads
|
//! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox
|
||||||
//! on. Before this module, all three backends hand-rolled the same `CreateFileMappingW` +
|
//! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the
|
||||||
//! `MapViewOfFile` and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` + `CloseHandle` —
|
//! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources
|
||||||
//! easy to drift or leak on an error path. [`Shm`] and [`SwDevice`] own those resources with RAII, so a
|
//! with RAII; [`PadChannel`] owns the two sections plus the delivery handshake.
|
||||||
//! backend just holds them and the cleanup (and ordering) happens by construction.
|
//!
|
||||||
|
//! **Why the channel is sealed** (`design/gamepad-channel-sealing.md`): the DATA section used to be a
|
||||||
|
//! `Global\pf…-shm-<index>` named section with an SY+LS DACL, which let any *sibling LocalService*
|
||||||
|
//! process open it by name to read the live controller input or inject/forge input and rumble — the
|
||||||
|
//! same name-open vector the frame ring closed (`design/idd-push-security.md`). The DATA section is now
|
||||||
|
//! UNNAMED with a SYSTEM-only DACL and reaches the driver exclusively as a handle this host duplicated
|
||||||
|
//! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad
|
||||||
|
//! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the
|
||||||
|
//! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the
|
||||||
|
//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. It carries only pids and
|
||||||
|
//! a handle VALUE (meaningless outside the target process), so tampering with it yields at worst a
|
||||||
|
//! gamepad DoS, never a read or an injection; the empirical floor from the frame work holds here too
|
||||||
|
//! (a LocalService token is DACL-denied `OpenProcess` on a UMDF WUDFHost for every access right).
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use std::os::windows::io::{FromRawHandle, OwnedHandle};
|
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
|
||||||
|
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||||
|
use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use windows::core::{w, HSTRING, PCWSTR};
|
use windows::core::{w, HSTRING, PCWSTR};
|
||||||
@@ -17,7 +32,10 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
|||||||
CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
|
CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
|
||||||
};
|
};
|
||||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||||
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
|
use windows::Win32::Foundation::{
|
||||||
|
DuplicateHandle, GetLastError, SetLastError, DUPLICATE_HANDLE_OPTIONS, ERROR_ALREADY_EXISTS,
|
||||||
|
HANDLE, INVALID_HANDLE_VALUE, WIN32_ERROR,
|
||||||
|
};
|
||||||
use windows::Win32::Security::Authorization::{
|
use windows::Win32::Security::Authorization::{
|
||||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||||
};
|
};
|
||||||
@@ -26,54 +44,102 @@ use windows::Win32::System::Memory::{
|
|||||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||||
};
|
};
|
||||||
|
use windows::Win32::System::Threading::{
|
||||||
|
GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
|
};
|
||||||
|
|
||||||
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps
|
/// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so
|
||||||
/// the view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three
|
/// `SECTION_MAP_READ | SECTION_MAP_WRITE` (== the driver's `FILE_MAP_RW`). Granted explicitly in
|
||||||
/// backends' hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`.
|
/// [`PadChannel::deliver_to`] instead of `DUPLICATE_SAME_ACCESS` (least privilege for the sealed
|
||||||
///
|
/// section — the driver's handle then can't take ownership / change security / delete the object).
|
||||||
/// SDDL `D:(A;;GA;;;SY)(A;;GA;;;LS)`: GENERIC_ALL to **SYSTEM** (the host creates the section and
|
const SECTION_MAP_RW: u32 = 0x0004 | 0x0002;
|
||||||
/// writes the live HID input report into it) and **LocalService** (the account the UMDF driver's
|
|
||||||
/// WUDFHost runs under, which reads it). The old SDDL granted **Everyone** (`WD`) — on the (mistaken)
|
/// An anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps the
|
||||||
/// assumption the driver needed a restricted token's broad access — letting any local user
|
/// view, then the [`OwnedHandle`] closes the section handle (in that order). Created either
|
||||||
/// `OpenFileMapping` the section to inject controller input or tamper the trusted channel
|
/// [unnamed](Self::create_unnamed) (the sealed DATA section — reachable only by handle duplication) or
|
||||||
/// (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29): the WUDFHost token is
|
/// [named](Self::create_named) (the bootstrap mailbox the driver opens by name).
|
||||||
/// `S-1-5-19` (LocalService), SYSTEM integrity, with **zero restricted SIDs** — so scoping to SY+LS is
|
|
||||||
/// sufficient for the driver and excludes normal (medium-IL, non-service) user processes.
|
|
||||||
pub(super) struct Shm {
|
pub(super) struct Shm {
|
||||||
/// Owns the section handle (closed on drop). Held only for ownership — never read after construction.
|
/// Owns the section handle (closed on drop). Also the duplication source for the sealed channel —
|
||||||
_handle: OwnedHandle,
|
/// see [`Shm::raw_handle`].
|
||||||
|
handle: OwnedHandle,
|
||||||
view: MEMORY_MAPPED_VIEW_ADDRESS,
|
view: MEMORY_MAPPED_VIEW_ADDRESS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a `SECURITY_ATTRIBUTES` from an SDDL literal (`psd` is OS-allocated and leaked — acceptable
|
||||||
|
/// for the handful of pad channels a host creates; it must outlive the returned `SECURITY_ATTRIBUTES`).
|
||||||
|
fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> {
|
||||||
|
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||||
|
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (leaked — see above).
|
||||||
|
unsafe {
|
||||||
|
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
|
sddl,
|
||||||
|
SDDL_REVISION_1,
|
||||||
|
&mut psd,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(SECURITY_ATTRIBUTES {
|
||||||
|
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
|
lpSecurityDescriptor: psd.0,
|
||||||
|
bInheritHandle: false.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl Shm {
|
impl Shm {
|
||||||
/// Create + zero a `size`-byte section named `name`, mapped read/write. The section handle is owned
|
/// Create + zero an **unnamed** `size`-byte section, mapped read/write — the sealed DATA section.
|
||||||
/// immediately, so any failure below (or the returned `Shm`'s drop) closes it.
|
/// SDDL `D:P(A;;GA;;;SY)` (SYSTEM-only, protected): with no name there is nothing to enumerate,
|
||||||
pub(super) fn create(name: &HSTRING, size: usize) -> Result<Shm> {
|
/// open, or squat, and the driver reaches it through a duplicated handle, which carries the
|
||||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
/// source's access without re-checking the object DACL (the exact property the frame ring
|
||||||
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (freed at process
|
/// validated on-glass — `design/idd-push-security.md`).
|
||||||
// exit — acceptable for a host-lifetime object).
|
pub(super) fn create_unnamed(size: usize) -> Result<Shm> {
|
||||||
unsafe {
|
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?;
|
||||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
Self::create_inner(&sa, PCWSTR::null(), size).context("create unnamed gamepad DATA section")
|
||||||
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"),
|
}
|
||||||
SDDL_REVISION_1,
|
|
||||||
&mut psd,
|
/// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL
|
||||||
None,
|
/// `D:(A;;GA;;;SY)(A;;GA;;;LS)`: SYSTEM (this host) + LocalService (the driver's WUDFHost opens it
|
||||||
)?;
|
/// by name). Safe to leave name-openable because it carries nothing exploitable (see the module
|
||||||
|
/// docs). **Squat-checked**: `Global\` names are creatable by any service holding
|
||||||
|
/// `SeCreateGlobalPrivilege` (LocalService has it), so if the name already exists —
|
||||||
|
/// `ERROR_ALREADY_EXISTS`, meaning `CreateFileMappingW` silently *opened* a pre-existing object we
|
||||||
|
/// don't control — we close and retry briefly (our own driver holds the name for microseconds per
|
||||||
|
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
|
||||||
|
/// another host instance's) mailbox.
|
||||||
|
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
|
||||||
|
let sa = sddl_sa(w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"))?;
|
||||||
|
for attempt in 0..5 {
|
||||||
|
if attempt > 0 {
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
|
||||||
|
unsafe { SetLastError(WIN32_ERROR(0)) };
|
||||||
|
let shm = Self::create_inner(&sa, PCWSTR(name.as_ptr()), size)
|
||||||
|
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
|
||||||
|
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
|
||||||
|
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
|
||||||
|
if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS {
|
||||||
|
return Ok(shm);
|
||||||
|
}
|
||||||
|
// `shm` drops here → unmap + close our handle to the foreign object, then retry.
|
||||||
}
|
}
|
||||||
let sa = SECURITY_ATTRIBUTES {
|
bail!(
|
||||||
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
"bootstrap mailbox {name} already exists and stayed alive across retries — another \
|
||||||
lpSecurityDescriptor: psd.0,
|
punktfunk-host instance is serving this pad index, or a local service is squatting the \
|
||||||
bInheritHandle: false.into(),
|
name (gamepad DoS attempt?)"
|
||||||
};
|
);
|
||||||
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the SDDL above.
|
}
|
||||||
|
|
||||||
|
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
|
||||||
|
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the caller's SDDL; the
|
||||||
|
// descriptor behind `sa` outlives this call (leaked by `sddl_sa`).
|
||||||
let map = unsafe {
|
let map = unsafe {
|
||||||
CreateFileMappingW(
|
CreateFileMappingW(
|
||||||
INVALID_HANDLE_VALUE,
|
INVALID_HANDLE_VALUE,
|
||||||
Some(&sa),
|
Some(sa),
|
||||||
PAGE_READWRITE,
|
PAGE_READWRITE,
|
||||||
0,
|
0,
|
||||||
size as u32,
|
size as u32,
|
||||||
PCWSTR(name.as_ptr()),
|
name,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
|
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
|
||||||
@@ -84,14 +150,11 @@ impl Shm {
|
|||||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
|
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
|
||||||
if view.Value.is_null() {
|
if view.Value.is_null() {
|
||||||
// `handle` drops here → closes the section. No view to unmap.
|
// `handle` drops here → closes the section. No view to unmap.
|
||||||
return Err(anyhow!("MapViewOfFile failed for {name}"));
|
return Err(anyhow!("MapViewOfFile failed"));
|
||||||
}
|
}
|
||||||
// SAFETY: `view` points at `size` writable bytes (just mapped).
|
// SAFETY: `view` points at `size` writable bytes (just mapped).
|
||||||
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
|
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
|
||||||
Ok(Shm {
|
Ok(Shm { handle, view })
|
||||||
_handle: handle,
|
|
||||||
view,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
|
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
|
||||||
@@ -99,11 +162,16 @@ impl Shm {
|
|||||||
pub(super) fn base(&self) -> *mut u8 {
|
pub(super) fn base(&self) -> *mut u8 {
|
||||||
self.view.Value as *mut u8
|
self.view.Value as *mut u8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The section handle as a borrowed `HANDLE` (the sealed channel's duplication source).
|
||||||
|
fn raw_handle(&self) -> HANDLE {
|
||||||
|
HANDLE(self.handle.as_raw_handle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Shm {
|
impl Drop for Shm {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `_handle` field closes the
|
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `handle` field closes the
|
||||||
// section (struct fields drop only after this `Drop::drop` returns).
|
// section (struct fields drop only after this `Drop::drop` returns).
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = UnmapViewOfFile(self.view);
|
let _ = UnmapViewOfFile(self.view);
|
||||||
@@ -111,6 +179,230 @@ impl Drop for Shm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The sealed-channel bootstrap broker ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two
|
||||||
|
/// consecutive pads on the same index can't hand the (persistent, out-of-band-devnode) driver the same
|
||||||
|
/// seq twice. Starts at 1.
|
||||||
|
static BOOT_SEQ: AtomicU32 = AtomicU32::new(1);
|
||||||
|
|
||||||
|
/// Hard cap on delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so a
|
||||||
|
/// tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment).
|
||||||
|
/// A legitimate pad needs exactly one (a driver restart within one pad lifetime is not a thing —
|
||||||
|
/// the WUDFHost dies with the devnode).
|
||||||
|
const MAX_DELIVERY_ATTEMPTS: u32 = 16;
|
||||||
|
|
||||||
|
/// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the
|
||||||
|
/// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's
|
||||||
|
/// WUDFHost a duplicated DATA handle once it publishes its pid. Owns both sections (RAII teardown —
|
||||||
|
/// dropping the channel closes the mailbox, whose *name* then disappears, which is how a persistent
|
||||||
|
/// (out-of-band-devnode) driver detects the host is gone).
|
||||||
|
pub(super) struct PadChannel {
|
||||||
|
data: Shm,
|
||||||
|
boot: Shm,
|
||||||
|
boot_name: String,
|
||||||
|
/// Last `driver_pid` acted on (delivered or rejected) — never retry the same value, so a failed
|
||||||
|
/// verify can't be spun into a hot loop by a static mailbox.
|
||||||
|
last_seen_pid: u32,
|
||||||
|
attempts: u32,
|
||||||
|
delivered: bool,
|
||||||
|
warned_proto: bool,
|
||||||
|
warned_cap: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PadChannel {
|
||||||
|
/// Create the unnamed DATA section (`data_size` bytes, zeroed — the caller stamps its layout and
|
||||||
|
/// magic) plus the named bootstrap mailbox, stamped `host_proto` first and `BOOT_MAGIC` last so a
|
||||||
|
/// driver only trusts a fully-initialized mailbox.
|
||||||
|
pub(super) fn create(boot_name: String, data_size: usize) -> Result<PadChannel> {
|
||||||
|
let data = Shm::create_unnamed(data_size)?;
|
||||||
|
let boot = Shm::create_named(
|
||||||
|
&HSTRING::from(boot_name.as_str()),
|
||||||
|
core::mem::size_of::<PadBootstrap>(),
|
||||||
|
)?;
|
||||||
|
let base = boot.base();
|
||||||
|
// SAFETY: `base` is the live, page-aligned mailbox view (>= size_of::<PadBootstrap>()); the
|
||||||
|
// field offsets are pinned by the proto's asserts and naturally aligned, so the atomic views
|
||||||
|
// are valid. `host_proto` is published BEFORE `magic` (Release) — a driver that observes the
|
||||||
|
// magic (Acquire) sees the version.
|
||||||
|
unsafe {
|
||||||
|
(*(base.add(core::mem::offset_of!(PadBootstrap, host_proto)) as *const AtomicU32))
|
||||||
|
.store(GAMEPAD_PROTO_VERSION, Ordering::Relaxed);
|
||||||
|
fence(Ordering::Release);
|
||||||
|
(*(base.add(core::mem::offset_of!(PadBootstrap, magic)) as *const AtomicU32))
|
||||||
|
.store(BOOT_MAGIC, Ordering::Release);
|
||||||
|
}
|
||||||
|
Ok(PadChannel {
|
||||||
|
data,
|
||||||
|
boot,
|
||||||
|
boot_name,
|
||||||
|
last_seen_pid: 0,
|
||||||
|
attempts: 0,
|
||||||
|
delivered: false,
|
||||||
|
warned_proto: false,
|
||||||
|
warned_cap: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The DATA section's mapped base (the host side of `XusbShm`/`PadShm`).
|
||||||
|
pub(super) fn data_base(&self) -> *mut u8 {
|
||||||
|
self.data.base()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bootstrap mailbox name (log labelling).
|
||||||
|
pub(super) fn boot_name(&self) -> &str {
|
||||||
|
&self.boot_name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic `u32` load from a mailbox field.
|
||||||
|
fn boot_load(&self, off: usize) -> u32 {
|
||||||
|
// SAFETY: the mailbox view is live (owned by `self.boot`), page-aligned, and every
|
||||||
|
// `PadBootstrap` u32 field offset is 4-aligned (proto asserts), so the atomic view is valid;
|
||||||
|
// no reference into the shared region outlives the load.
|
||||||
|
unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms
|
||||||
|
/// cadence) and from [`Self::deliver_eager`]. Cheap when idle: two atomic loads.
|
||||||
|
pub(super) fn pump(&mut self) {
|
||||||
|
// Version diagnostics: the driver writes its own proto version even when it refuses to
|
||||||
|
// publish a pid (host/driver mismatch), so the operator sees WHY the pad never attaches.
|
||||||
|
let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto));
|
||||||
|
if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto {
|
||||||
|
self.warned_proto = true;
|
||||||
|
tracing::warn!(
|
||||||
|
mailbox = %self.boot_name,
|
||||||
|
driver_proto = drv_proto,
|
||||||
|
host_proto = GAMEPAD_PROTO_VERSION,
|
||||||
|
"gamepad driver/host protocol mismatch on the bootstrap mailbox — update the \
|
||||||
|
drivers: punktfunk-host.exe driver install --gamepad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid));
|
||||||
|
if pid == 0 || pid == self.last_seen_pid {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.last_seen_pid = pid;
|
||||||
|
if self.attempts >= MAX_DELIVERY_ATTEMPTS {
|
||||||
|
if !self.warned_cap {
|
||||||
|
self.warned_cap = true;
|
||||||
|
tracing::warn!(
|
||||||
|
mailbox = %self.boot_name,
|
||||||
|
attempts = self.attempts,
|
||||||
|
"gamepad channel delivery cap reached — the bootstrap mailbox keeps changing \
|
||||||
|
its driver pid (tampering?); no further handles will be duplicated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.attempts += 1;
|
||||||
|
match self.deliver_to(pid) {
|
||||||
|
Ok(seq) => {
|
||||||
|
self.delivered = true;
|
||||||
|
tracing::info!(
|
||||||
|
mailbox = %self.boot_name,
|
||||||
|
wudf_pid = pid,
|
||||||
|
seq,
|
||||||
|
"sealed gamepad channel delivered (DATA handle duplicated into the driver's \
|
||||||
|
WUDFHost)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
mailbox = %self.boot_name,
|
||||||
|
pid,
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"sealed gamepad channel delivery failed — will retry when the mailbox reports \
|
||||||
|
a different driver pid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine
|
||||||
|
/// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver
|
||||||
|
/// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target
|
||||||
|
/// process (nothing to reap — there is no fallible step after the duplication).
|
||||||
|
fn deliver_to(&self, pid: u32) -> Result<u32> {
|
||||||
|
// SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the
|
||||||
|
// `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the
|
||||||
|
// synchronous check and forms no lasting alias.
|
||||||
|
let process = unsafe {
|
||||||
|
let h = OpenProcess(
|
||||||
|
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
|
false,
|
||||||
|
pid,
|
||||||
|
)
|
||||||
|
.context("OpenProcess(PROCESS_DUP_HANDLE) on the mailbox-reported pid")?;
|
||||||
|
let process = OwnedHandle::from_raw_handle(h.0 as _);
|
||||||
|
crate::capture::idd_push::verify_is_wudfhost(
|
||||||
|
HANDLE(process.as_raw_handle()),
|
||||||
|
pid,
|
||||||
|
"gamepad-channel",
|
||||||
|
)?;
|
||||||
|
process
|
||||||
|
};
|
||||||
|
let mut remote = HANDLE::default();
|
||||||
|
// SAFETY: `self.data.raw_handle()` is the live section handle this channel owns;
|
||||||
|
// `process` is the live PROCESS_DUP_HANDLE target; `&mut remote` is a valid out-param.
|
||||||
|
// Least privilege: the pad driver only MAPS the DATA section read/write (its `FILE_MAP_RW` =
|
||||||
|
// `SECTION_MAP_READ | SECTION_MAP_WRITE`), so grant exactly that instead of copying our
|
||||||
|
// full-access creator handle via `DUPLICATE_SAME_ACCESS` (Chen: don't over-grant unnamed
|
||||||
|
// shared objects — a compromised driver's handle then can't `WRITE_DAC`/`DELETE` the section).
|
||||||
|
unsafe {
|
||||||
|
DuplicateHandle(
|
||||||
|
GetCurrentProcess(),
|
||||||
|
self.data.raw_handle(),
|
||||||
|
HANDLE(process.as_raw_handle()),
|
||||||
|
&mut remote,
|
||||||
|
SECTION_MAP_RW,
|
||||||
|
false,
|
||||||
|
DUPLICATE_HANDLE_OPTIONS(0),
|
||||||
|
)
|
||||||
|
.context("DuplicateHandle(gamepad DATA section) into the driver's WUDFHost")?;
|
||||||
|
}
|
||||||
|
let value = remote.0 as usize as u64;
|
||||||
|
let base = self.boot.base();
|
||||||
|
let seq = BOOT_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||||
|
// SAFETY: live, page-aligned mailbox view; `data_handle` is 8-aligned and `handle_pid`/
|
||||||
|
// `handle_seq` 4-aligned (proto asserts). The handle value + owning pid are published BEFORE
|
||||||
|
// the seq (Release) — a driver that observes the new seq (Acquire) sees a complete delivery.
|
||||||
|
unsafe {
|
||||||
|
(*(base.add(core::mem::offset_of!(PadBootstrap, data_handle)) as *const AtomicU64))
|
||||||
|
.store(value, Ordering::Relaxed);
|
||||||
|
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_pid)) as *const AtomicU32))
|
||||||
|
.store(pid, Ordering::Relaxed);
|
||||||
|
fence(Ordering::Release);
|
||||||
|
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32))
|
||||||
|
.store(seq, Ordering::Release);
|
||||||
|
}
|
||||||
|
Ok(seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or
|
||||||
|
/// rejected) or `timeout` passes. Closes the identity race for the DualShock 4 (the driver reads
|
||||||
|
/// `device_type` from the DATA section when hidclass asks for descriptors — the channel should be
|
||||||
|
/// attached by then); the regular service pump takes over afterwards either way.
|
||||||
|
pub(super) fn deliver_eager(&mut self, timeout: Duration) {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
self.pump();
|
||||||
|
if self.last_seen_pid != 0 || Instant::now() >= deadline {
|
||||||
|
if !self.delivered {
|
||||||
|
tracing::debug!(
|
||||||
|
mailbox = %self.boot_name,
|
||||||
|
"eager gamepad-channel delivery window passed without an attach — the \
|
||||||
|
service pump keeps polling (driver-attach diagnosis follows if it stays \
|
||||||
|
silent)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
|
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
|
||||||
/// `SwDeviceClose` each backend used to call in its `Drop`.
|
/// `SwDeviceClose` each backend used to call in its `Drop`.
|
||||||
pub(super) struct SwDevice(HSWDEVICE);
|
pub(super) struct SwDevice(HSWDEVICE);
|
||||||
@@ -151,7 +443,7 @@ pub(super) struct DriverAttach {
|
|||||||
inf: &'static str,
|
inf: &'static str,
|
||||||
/// The driver's own debug log, referenced in the diagnosis line.
|
/// The driver's own debug log, referenced in the diagnosis line.
|
||||||
driver_log: &'static str,
|
driver_log: &'static str,
|
||||||
/// Section name, for log lines.
|
/// Bootstrap-mailbox name, for log lines (the DATA section is unnamed).
|
||||||
shm_name: String,
|
shm_name: String,
|
||||||
/// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path).
|
/// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path).
|
||||||
instance_id: Option<String>,
|
instance_id: Option<String>,
|
||||||
@@ -241,8 +533,8 @@ impl DriverAttach {
|
|||||||
devnode = %devnode,
|
devnode = %devnode,
|
||||||
driver_log = self.driver_log,
|
driver_log = self.driver_log,
|
||||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||||
driver is serving it (games will not see it); an old (pre-health) driver also reads as \
|
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||||
not-attached: update with punktfunk-host.exe driver install --gamepad"
|
reads as not-attached: update with punktfunk-host.exe driver install --gamepad"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
//! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver
|
//! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver
|
||||||
//! (`packaging/windows/xusb-driver`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360
|
//! (`packaging/windows/drivers/pf-xusb`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360
|
||||||
//! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel
|
//! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel
|
||||||
//! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_<index>` devnode (the driver loads on it and
|
//! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_<index>` devnode (the driver loads on it and
|
||||||
//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into the shared section
|
//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into an **unnamed** shared
|
||||||
//! `Global\pfxusb-shm-<index>`. GameStream/Moonlight already speak the XInput conventions (low-16
|
//! DATA section the driver reaches over the **sealed channel** ([`PadChannel`] — handle duplicated
|
||||||
//! button bits, sticks −32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1.
|
//! into its WUDFHost, bootstrapped via `Global\pfxusb-boot-<index>`; see
|
||||||
|
//! `design/gamepad-channel-sealing.md`). GameStream/Moonlight already speak the XInput conventions
|
||||||
|
//! (low-16 button bits, sticks −32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1.
|
||||||
//!
|
//!
|
||||||
//! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver
|
//! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver
|
||||||
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays
|
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays
|
||||||
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
|
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
|
||||||
//!
|
|
||||||
//! NB: the driver currently maps `Global\pfxusb-shm-0` (hardcoded), so a single pad (index 0) is
|
|
||||||
//! fully correct; mixed multi-pad needs the driver to read its own index first (same limitation as
|
|
||||||
//! the DualSense backend).
|
|
||||||
|
|
||||||
|
use super::gamepad_raii::PadChannel;
|
||||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
|
use std::time::Duration;
|
||||||
|
use windows::core::{w, GUID, HRESULT, PCWSTR};
|
||||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||||
};
|
};
|
||||||
@@ -41,6 +41,7 @@ const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry);
|
|||||||
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
|
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
|
||||||
const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
|
const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
|
||||||
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
|
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
|
||||||
|
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
|
||||||
|
|
||||||
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
|
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
|
||||||
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics).
|
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics).
|
||||||
@@ -100,7 +101,7 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
|||||||
.encode_utf16()
|
.encode_utf16()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect();
|
.collect();
|
||||||
// The pad index, stamped into the device Location — the driver reads it to map `pfxusb-shm-<index>`
|
// The pad index, stamped into the device Location — the driver reads it to poll `pfxusb-boot-<index>`
|
||||||
// (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event).
|
// (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event).
|
||||||
let loc: Vec<u16> = format!("{index}")
|
let loc: Vec<u16> = format!("{index}")
|
||||||
.encode_utf16()
|
.encode_utf16()
|
||||||
@@ -171,12 +172,13 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
|||||||
Ok((hsw, ctx.instance_id()))
|
Ok((hsw, ctx.instance_id()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the mapped shared section.
|
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the sealed shared-memory channel.
|
||||||
struct XusbWinPad {
|
struct XusbWinPad {
|
||||||
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
|
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
|
||||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||||
/// Owns `Global\pfxusb-shm-<index>` (the section + its mapped view; drop unmaps + closes).
|
/// The sealed channel: the unnamed DATA section (the `XusbShm`) + the bootstrap mailbox + the
|
||||||
shm: super::gamepad_raii::Shm,
|
/// handle-delivery state machine (drop closes both sections).
|
||||||
|
channel: PadChannel,
|
||||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||||
attach: super::gamepad_raii::DriverAttach,
|
attach: super::gamepad_raii::DriverAttach,
|
||||||
packet: u32,
|
packet: u32,
|
||||||
@@ -184,17 +186,18 @@ struct XusbWinPad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl XusbWinPad {
|
impl XusbWinPad {
|
||||||
/// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode.
|
/// Create the sealed channel (unnamed DATA section + `Global\pfxusb-boot-<index>` mailbox), stamp
|
||||||
|
/// the pad index then the magic LAST, spawn the devnode, and eagerly deliver the DATA handle once
|
||||||
|
/// the driver publishes its pid.
|
||||||
fn open(index: u8) -> Result<XusbWinPad> {
|
fn open(index: u8) -> Result<XusbWinPad> {
|
||||||
// Permissive-DACL named section the WUDFHost (whatever account) can open; `Shm` owns the
|
let boot_name = pf_driver_proto::gamepad::xusb_boot_name(index);
|
||||||
// section handle + its mapped view (zero-filled) and unmaps/closes on drop.
|
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||||
let shm_name = pf_driver_proto::gamepad::xusb_shm_name(index);
|
let base = channel.data_base();
|
||||||
let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?;
|
// The section arrives zeroed; stamp the pad index (the driver validates it against its own
|
||||||
let base = shm.base();
|
// devnode index on attach) then the magic LAST (the driver only accepts it once magic is set).
|
||||||
// Zero the section then stamp the magic LAST (the driver only accepts it once magic is set).
|
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX is in range.
|
||||||
// SAFETY: base points at SHM_SIZE writable bytes.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::write_bytes(base, 0, SHM_SIZE);
|
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
}
|
}
|
||||||
let (hsw, instance_id) = match create_swdevice(index) {
|
let (hsw, instance_id) = match create_swdevice(index) {
|
||||||
@@ -205,14 +208,18 @@ impl XusbWinPad {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||||
|
// Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it
|
||||||
|
// the DATA handle before we return means the pad is live for the game's first XInput poll.
|
||||||
|
// On a missing/old driver this waits out the window once and the service pump takes over.
|
||||||
|
channel.deliver_eager(Duration::from_millis(1500));
|
||||||
Ok(XusbWinPad {
|
Ok(XusbWinPad {
|
||||||
_sw,
|
_sw,
|
||||||
shm,
|
channel,
|
||||||
attach: super::gamepad_raii::DriverAttach::new(
|
attach: super::gamepad_raii::DriverAttach::new(
|
||||||
"pf_xusb",
|
"pf_xusb",
|
||||||
"pf_xusb.inf",
|
"pf_xusb.inf",
|
||||||
"C:\\Users\\Public\\pfxusb-driver.log",
|
"C:\\Users\\Public\\pfxusb-driver.log",
|
||||||
shm_name,
|
boot_name,
|
||||||
instance_id,
|
instance_id,
|
||||||
),
|
),
|
||||||
packet: 0,
|
packet: 0,
|
||||||
@@ -225,7 +232,7 @@ impl XusbWinPad {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) {
|
fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) {
|
||||||
self.packet = self.packet.wrapping_add(1);
|
self.packet = self.packet.wrapping_add(1);
|
||||||
let base = self.shm.base();
|
let base = self.channel.data_base();
|
||||||
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
|
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
|
||||||
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
|
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
|
||||||
// writes. Single owner (`&mut self`), so no concurrent writer races these stores.
|
// writes. Single owner (`&mut self`), so no concurrent writer races these stores.
|
||||||
@@ -242,10 +249,12 @@ impl XusbWinPad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns
|
/// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns
|
||||||
/// `(large, small)` motor levels (0..=255) when a new one arrived. Also feeds the driver-attach
|
/// `(large, small)` motor levels (0..=255) when a new one arrived. Also ticks the sealed-channel
|
||||||
/// health watcher (the driver stamps `driver_proto` at device add + on every serviced IOCTL).
|
/// delivery (a late-binding driver gets its handle here) and feeds the driver-attach health
|
||||||
|
/// watcher (the driver stamps `driver_proto` once it maps the delivered section + per IOCTL).
|
||||||
fn service(&mut self) -> Option<(u8, u8)> {
|
fn service(&mut self) -> Option<(u8, u8)> {
|
||||||
let base = self.shm.base();
|
self.channel.pump();
|
||||||
|
let base = self.channel.data_base();
|
||||||
// SAFETY: base points at SHM_SIZE bytes.
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) };
|
let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) };
|
||||||
self.attach.observe(proto);
|
self.attach.observe(proto);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -739,7 +739,7 @@ NOTES:
|
|||||||
"\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\
|
"\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\
|
||||||
\x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\
|
\x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\
|
||||||
\x20 punktfunk-host service uninstall remove the service + firewall rules\n\
|
\x20 punktfunk-host service uninstall remove the service + firewall rules\n\
|
||||||
\x20 punktfunk-host service start|stop|status\n\
|
\x20 punktfunk-host service start|stop|restart|status\n\
|
||||||
\x20 config: %ProgramData%\\punktfunk\\host.env\n\
|
\x20 config: %ProgramData%\\punktfunk\\host.env\n\
|
||||||
\nWINDOWS DIAGNOSTICS:\n\
|
\nWINDOWS DIAGNOSTICS:\n\
|
||||||
\x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\
|
\x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(list_gpus))
|
.routes(routes!(list_gpus))
|
||||||
.routes(routes!(set_gpu_preference))
|
.routes(routes!(set_gpu_preference))
|
||||||
.routes(routes!(get_status))
|
.routes(routes!(get_status))
|
||||||
|
.routes(routes!(get_local_summary))
|
||||||
.routes(routes!(list_paired_clients))
|
.routes(routes!(list_paired_clients))
|
||||||
.routes(routes!(unpair_client))
|
.routes(routes!(unpair_client))
|
||||||
.routes(routes!(get_pairing_status))
|
.routes(routes!(get_pairing_status))
|
||||||
@@ -353,6 +354,30 @@ struct StreamInfo {
|
|||||||
codec: ApiCodec,
|
codec: ApiCodec,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,
|
||||||
|
/// no fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see
|
||||||
|
/// `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the
|
||||||
|
/// per-user tray process cannot authenticate — this narrow read-only route is its status source.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct LocalSummary {
|
||||||
|
/// Host version (mirrors `/health`).
|
||||||
|
version: String,
|
||||||
|
/// True while the video stream thread is running.
|
||||||
|
video_streaming: bool,
|
||||||
|
/// True while the audio stream thread is running.
|
||||||
|
audio_streaming: bool,
|
||||||
|
/// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop).
|
||||||
|
session: Option<SessionInfo>,
|
||||||
|
/// Number of pinned (paired) GameStream client certificates.
|
||||||
|
paired_clients: u32,
|
||||||
|
/// Number of paired native (punktfunk/1) devices.
|
||||||
|
native_paired_clients: u32,
|
||||||
|
/// True while a GameStream pairing handshake is parked waiting for the user's PIN.
|
||||||
|
pin_pending: bool,
|
||||||
|
/// Native pairing knocks awaiting the operator's approval (count only).
|
||||||
|
pending_approvals: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// A paired (certificate-pinned) Moonlight client.
|
/// A paired (certificate-pinned) Moonlight client.
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
struct PairedClient {
|
struct PairedClient {
|
||||||
@@ -488,13 +513,34 @@ where
|
|||||||
|
|
||||||
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
|
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
|
||||||
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
|
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
|
||||||
/// `/api/v1/health` stays open for probes. The cert path authorizes only the read-only allowlist
|
/// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only
|
||||||
|
/// (the tray icon's status source). The cert path authorizes only the read-only allowlist
|
||||||
/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined
|
/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined
|
||||||
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
|
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
|
||||||
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
|
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
|
||||||
if req.uri().path() == "/api/v1/health" {
|
if req.uri().path() == "/api/v1/health" {
|
||||||
return next.run(req).await; // liveness probe is always open
|
return next.run(req).await; // liveness probe is always open
|
||||||
}
|
}
|
||||||
|
// The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but
|
||||||
|
// confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators-
|
||||||
|
// DACL'd on Windows, so the per-user tray process cannot authenticate — this one narrow
|
||||||
|
// read-only route is deliberately all it needs. Not on the cert allowlist: LAN mTLS clients
|
||||||
|
// already have the richer `/status`. (No PeerAddr ⇒ a unit test → treat as loopback, matching
|
||||||
|
// the bearer path below.)
|
||||||
|
if req.uri().path() == "/api/v1/local/summary" {
|
||||||
|
let from_loopback = req
|
||||||
|
.extensions()
|
||||||
|
.get::<PeerAddr>()
|
||||||
|
.is_none_or(|a| a.0.ip().is_loopback());
|
||||||
|
return if from_loopback {
|
||||||
|
next.run(req).await
|
||||||
|
} else {
|
||||||
|
api_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"the local summary is loopback-only",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
// A paired native client authenticates by its mTLS certificate — the same identity + trust the
|
// A paired native client authenticates by its mTLS certificate — the same identity + trust the
|
||||||
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
|
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
|
||||||
// authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration
|
// authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration
|
||||||
@@ -944,6 +990,45 @@ async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local status summary for the tray icon
|
||||||
|
///
|
||||||
|
/// Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
|
||||||
|
/// names). Unauthenticated, but served to loopback peers only.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/local/summary",
|
||||||
|
tag = "host",
|
||||||
|
operation_id = "getLocalSummary",
|
||||||
|
// Override the document-global bearerAuth: loopback peers are exempt in `require_auth`.
|
||||||
|
security(()),
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Non-sensitive local host status (loopback peers only)", body = LocalSummary),
|
||||||
|
(status = UNAUTHORIZED, description = "Non-loopback peer", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
|
||||||
|
let session = st.app.launch.lock().unwrap().map(|l| SessionInfo {
|
||||||
|
width: l.width,
|
||||||
|
height: l.height,
|
||||||
|
fps: l.fps,
|
||||||
|
});
|
||||||
|
let (native_paired_clients, pending_approvals) = st
|
||||||
|
.native
|
||||||
|
.as_ref()
|
||||||
|
.map(|n| (n.status().paired_clients, n.pending().len() as u32))
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
Json(LocalSummary {
|
||||||
|
version: env!("PUNKTFUNK_VERSION").into(),
|
||||||
|
video_streaming: st.app.streaming.load(Ordering::SeqCst),
|
||||||
|
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
||||||
|
session,
|
||||||
|
paired_clients: st.app.paired.lock().unwrap().len() as u32,
|
||||||
|
native_paired_clients,
|
||||||
|
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
||||||
|
pending_approvals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// List paired clients
|
/// List paired clients
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -2031,6 +2116,61 @@ mod tests {
|
|||||||
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
|
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
|
||||||
|
/// rejected even though the route needs no bearer token, and the body never carries secret
|
||||||
|
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
||||||
|
let np = Arc::new(
|
||||||
|
crate::native_pairing::NativePairing::load_with(
|
||||||
|
Some(
|
||||||
|
std::env::temp_dir()
|
||||||
|
.join(format!("pf-mgmt-summary-{}.json", std::process::id())),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
np.add("secret-device-name", "deadbeefcafe0123").unwrap();
|
||||||
|
let app = test_app_native(test_state(), np);
|
||||||
|
|
||||||
|
// Loopback peer, NO auth header → 200 with the expected shape.
|
||||||
|
let mut req = get_req("/api/v1/local/summary");
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(PeerAddr("127.0.0.1:40000".parse().unwrap()));
|
||||||
|
let (status, body) = send(&app, req).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["video_streaming"], false);
|
||||||
|
assert_eq!(body["native_paired_clients"], 1);
|
||||||
|
assert_eq!(body["pending_approvals"], 0);
|
||||||
|
assert!(body["version"].is_string());
|
||||||
|
// No secret material anywhere in the body (paired name / fingerprint must not leak).
|
||||||
|
let raw = body.to_string();
|
||||||
|
assert!(
|
||||||
|
!raw.contains("deadbeefcafe0123") && !raw.contains("secret-device-name"),
|
||||||
|
"summary must not leak fingerprints or device names: {raw}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same request from a LAN peer → rejected (route is loopback-gated, not just tokenless).
|
||||||
|
let mut req = get_req("/api/v1/local/summary");
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(PeerAddr("192.168.1.50:40000".parse().unwrap()));
|
||||||
|
let (status, _) = send(&app, req).await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"the local summary must be rejected for a LAN peer"
|
||||||
|
);
|
||||||
|
|
||||||
|
// IPv6 loopback counts as loopback.
|
||||||
|
let mut req = get_req("/api/v1/local/summary");
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(PeerAddr("[::1]:40000".parse().unwrap()));
|
||||||
|
let (status, _) = send(&app, req).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "::1 is a loopback peer");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bearer_token_is_enforced() {
|
async fn bearer_token_is_enforced() {
|
||||||
let app = test_app(test_state(), Some("sekrit"));
|
let app = test_app(test_state(), Some("sekrit"));
|
||||||
|
|||||||
@@ -39,11 +39,13 @@ pub(crate) enum MonitorKey {
|
|||||||
Session(u64),
|
Session(u64),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID.
|
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the
|
||||||
|
/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target).
|
||||||
pub(crate) struct AddedMonitor {
|
pub(crate) struct AddedMonitor {
|
||||||
pub key: MonitorKey,
|
pub key: MonitorKey,
|
||||||
pub target_id: u32,
|
pub target_id: u32,
|
||||||
pub luid: LUID,
|
pub luid: LUID,
|
||||||
|
pub wudf_pid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
|
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
|
||||||
@@ -91,6 +93,9 @@ struct Monitor {
|
|||||||
key: MonitorKey,
|
key: MonitorKey,
|
||||||
target_id: u32,
|
target_id: u32,
|
||||||
luid: LUID,
|
luid: LUID,
|
||||||
|
/// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the
|
||||||
|
/// IDD-push capturer knows where to duplicate the sealed frame channel's handles.
|
||||||
|
wudf_pid: u32,
|
||||||
gdi_name: Option<String>,
|
gdi_name: Option<String>,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
@@ -109,6 +114,7 @@ impl Monitor {
|
|||||||
adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
|
adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
|
||||||
gdi_name: n,
|
gdi_name: n,
|
||||||
target_id: self.target_id,
|
target_id: self.target_id,
|
||||||
|
wudf_pid: self.wudf_pid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +172,14 @@ pub(crate) fn vdm() -> &'static VirtualDisplayManager {
|
|||||||
.expect("VirtualDisplayManager used before a backend initialised it")
|
.expect("VirtualDisplayManager used before a backend initialised it")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The live pf-vdisplay control-device handle, for the IDD-push capturer's sealed-channel delivery
|
||||||
|
/// (`IOCTL_SET_FRAME_CHANNEL`). Safe to hand out as a bare `HANDLE`: the device lives in a `OnceLock`
|
||||||
|
/// that is never cleared or closed for the process lifetime. `None` before the first backend open —
|
||||||
|
/// impossible for a capturer, which only exists on a monitor the manager created.
|
||||||
|
pub(crate) fn control_device_handle() -> Option<HANDLE> {
|
||||||
|
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||||
|
}
|
||||||
|
|
||||||
impl VirtualDisplayManager {
|
impl VirtualDisplayManager {
|
||||||
pub(crate) fn backend_name(&self) -> &'static str {
|
pub(crate) fn backend_name(&self) -> &'static str {
|
||||||
self.driver.name()
|
self.driver.name()
|
||||||
@@ -436,6 +450,7 @@ impl VirtualDisplayManager {
|
|||||||
key: added.key,
|
key: added.key,
|
||||||
target_id: added.target_id,
|
target_id: added.target_id,
|
||||||
luid: added.luid,
|
luid: added.luid,
|
||||||
|
wudf_pid: added.wudf_pid,
|
||||||
gdi_name,
|
gdi_name,
|
||||||
mode,
|
mode,
|
||||||
stop,
|
stop,
|
||||||
|
|||||||
@@ -158,6 +158,33 @@ unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
|
|||||||
.context("pf-vdisplay SET_RENDER_ADAPTER")
|
.context("pf-vdisplay SET_RENDER_ADAPTER")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deliver a monitor's sealed frame channel to the driver: the handle values `req` carries were just
|
||||||
|
/// duplicated into the driver's WUDFHost by the IDD-push capturer's broker (`idd_push::ChannelBroker`),
|
||||||
|
/// and on IOCTL success the DRIVER owns them. No output buffer. The caller reaps the remote duplicates
|
||||||
|
/// on failure (the broker's `DUPLICATE_CLOSE_SOURCE` sweep) so no path leaks WUDFHost handles.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `dev` must be a live pf-vdisplay control handle (see [`super::manager::control_device_handle`]).
|
||||||
|
pub(crate) unsafe fn send_frame_channel(
|
||||||
|
dev: HANDLE,
|
||||||
|
req: &control::SetFrameChannelRequest,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut none: [u8; 0] = [];
|
||||||
|
// SAFETY: per this fn's contract `dev` is the live control handle. `bytes_of(req)` borrows the
|
||||||
|
// caller's request for the duration of this synchronous call as the input bytes; `none` is empty,
|
||||||
|
// so there is no output buffer.
|
||||||
|
unsafe {
|
||||||
|
ioctl(
|
||||||
|
dev,
|
||||||
|
control::IOCTL_SET_FRAME_CHANNEL,
|
||||||
|
bytemuck::bytes_of(req),
|
||||||
|
&mut none,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map(|_| ())
|
||||||
|
.context("pf-vdisplay SET_FRAME_CHANNEL")
|
||||||
|
}
|
||||||
|
|
||||||
unsafe fn open_device() -> Result<HANDLE> {
|
unsafe fn open_device() -> Result<HANDLE> {
|
||||||
let hdev = SetupDiGetClassDevsW(
|
let hdev = SetupDiGetClassDevsW(
|
||||||
Some(&PF_VDISPLAY_INTERFACE),
|
Some(&PF_VDISPLAY_INTERFACE),
|
||||||
@@ -354,12 +381,13 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
HighPart: reply.adapter_luid_high,
|
HighPart: reply.adapter_luid_high,
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})",
|
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x}, wudf_pid={})",
|
||||||
mode.width,
|
mode.width,
|
||||||
mode.height,
|
mode.height,
|
||||||
mode.refresh_hz,
|
mode.refresh_hz,
|
||||||
reply.target_id,
|
reply.target_id,
|
||||||
luid.LowPart
|
luid.LowPart,
|
||||||
|
reply.wudf_pid
|
||||||
);
|
);
|
||||||
// Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id?
|
// Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id?
|
||||||
// A pre-Phase-2 driver leaves resolved_monitor_id=0 (it ignored the field); a current driver echoes
|
// A pre-Phase-2 driver leaves resolved_monitor_id=0 (it ignored the field); a current driver echoes
|
||||||
@@ -395,6 +423,7 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
key: MonitorKey::Session(session_id),
|
key: MonitorKey::Session(session_id),
|
||||||
target_id: reply.target_id,
|
target_id: reply.target_id,
|
||||||
luid,
|
luid,
|
||||||
|
wudf_pid: reply.wudf_pid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,9 +162,28 @@ fn install_gamepad(dir: &Path) -> Result<()> {
|
|||||||
eprintln!("warning: pnputil /add-driver {} failed", inf.display());
|
eprintln!("warning: pnputil /add-driver {} failed", inf.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sweep pad devnodes, INCLUDING phantoms a host crash / service stop left behind: a re-created
|
||||||
|
// SwDevice with a known instance id REVIVES the existing devnode with its previously-bound
|
||||||
|
// driver — it never re-ranks against the store — so after an upgrade the old driver keeps
|
||||||
|
// serving (or, across the v1→v2 sealed-channel fence, fails closed and the pad plays dead).
|
||||||
|
// Proven in the field on the RTX box: a v1 phantom pinned the old package through a v2
|
||||||
|
// install. The devnodes are per-session objects the host recreates on demand, so removing
|
||||||
|
// them at driver-install time is always safe; the next pad binds the fresh package.
|
||||||
|
remove_pad_devnodes();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `pnputil /remove-device` every punktfunk virtual-pad devnode (live or phantom).
|
||||||
|
fn remove_pad_devnodes() {
|
||||||
|
for id in pad_instance_ids() {
|
||||||
|
if run_quiet("pnputil", &["/remove-device", &id]) {
|
||||||
|
println!("removed stale pad devnode {id}");
|
||||||
|
} else {
|
||||||
|
eprintln!("warning: pnputil /remove-device {id} failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── `driver uninstall [--gamepad]` ──────────────────────────────────────────────────────────────
|
// ── `driver uninstall [--gamepad]` ──────────────────────────────────────────────────────────────
|
||||||
// The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our
|
// The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our
|
||||||
// virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver
|
// virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver
|
||||||
@@ -204,6 +223,9 @@ fn uninstall_pf_vdisplay() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn uninstall_gamepad() -> Result<()> {
|
fn uninstall_gamepad() -> Result<()> {
|
||||||
|
// Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall
|
||||||
|
// fixed), then the store packages.
|
||||||
|
remove_pad_devnodes();
|
||||||
delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb"]);
|
delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb"]);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -235,6 +257,28 @@ fn pf_vdisplay_instance_ids() -> Vec<String> {
|
|||||||
ids
|
ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Instance IDs of punktfunk virtual-pad devnodes (`SWD\PUNKTFUNK\…`), INCLUDING phantoms left by
|
||||||
|
/// a host crash / service stop (`pnputil /enum-devices` lists disconnected devnodes too). Same
|
||||||
|
/// un-localized VALUE-side parsing as [`pf_vdisplay_instance_ids`]; matched on the instance-id
|
||||||
|
/// prefix itself — the pads span two device classes (HIDClass + System), so no `/class` filter.
|
||||||
|
fn pad_instance_ids() -> Vec<String> {
|
||||||
|
let out = run_capture("pnputil", &["/enum-devices"]);
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for block in out.split("\r\n\r\n").flat_map(|b| b.split("\n\n")) {
|
||||||
|
let Some(first) = block.lines().find(|l| !l.trim().is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((_, value)) = first.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let id = value.trim();
|
||||||
|
if id.to_ascii_uppercase().starts_with("SWD\\PUNKTFUNK\\") && !id.contains(' ') {
|
||||||
|
ids.push(id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete every driver-store package (`%WINDIR%\INF\oem*.inf`) whose INF text mentions one of
|
/// Delete every driver-store package (`%WINDIR%\INF\oem*.inf`) whose INF text mentions one of
|
||||||
/// `needles` — our driver names are unique enough that a content match identifies the package
|
/// `needles` — our driver names are unique enough that a content match identifies the package
|
||||||
/// without parsing `pnputil /enum-drivers`' localized output. `/uninstall /force` also unbinds it
|
/// without parsing `pnputil /enum-drivers`' localized output. `/uninstall /force` also unbinds it
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ pub fn main(args: &[String]) -> Result<()> {
|
|||||||
Some("uninstall") => uninstall(),
|
Some("uninstall") => uninstall(),
|
||||||
Some("start") => sc(&["start", SERVICE_NAME]),
|
Some("start") => sc(&["start", SERVICE_NAME]),
|
||||||
Some("stop") => sc(&["stop", SERVICE_NAME]),
|
Some("stop") => sc(&["stop", SERVICE_NAME]),
|
||||||
|
Some("restart") => restart(),
|
||||||
Some("status") => sc(&["query", SERVICE_NAME]),
|
Some("status") => sc(&["query", SERVICE_NAME]),
|
||||||
_ => {
|
_ => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -102,6 +103,7 @@ pub fn main(args: &[String]) -> Result<()> {
|
|||||||
\x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\
|
\x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\
|
||||||
\x20 punktfunk-host service start start the service now\n\
|
\x20 punktfunk-host service start start the service now\n\
|
||||||
\x20 punktfunk-host service stop stop the service\n\
|
\x20 punktfunk-host service stop stop the service\n\
|
||||||
|
\x20 punktfunk-host service restart stop, wait for exit, start again\n\
|
||||||
\x20 punktfunk-host service status query the service\n\n\
|
\x20 punktfunk-host service status query the service\n\n\
|
||||||
Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\"
|
Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\"
|
||||||
);
|
);
|
||||||
@@ -691,6 +693,40 @@ fn install(args: &[String]) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `service restart`: stop, wait for the service to actually reach Stopped (a bare
|
||||||
|
/// `sc stop && sc start` races the stop — START fails with "instance already running" while the
|
||||||
|
/// old process winds down), then start. The tray icon's Restart action runs this, elevated.
|
||||||
|
fn restart() -> Result<()> {
|
||||||
|
use windows_service::service::{ServiceAccess, ServiceState};
|
||||||
|
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||||
|
|
||||||
|
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||||
|
.context("open Service Control Manager (run elevated)")?;
|
||||||
|
let svc = manager
|
||||||
|
.open_service(
|
||||||
|
SERVICE_NAME,
|
||||||
|
ServiceAccess::STOP | ServiceAccess::QUERY_STATUS | ServiceAccess::START,
|
||||||
|
)
|
||||||
|
.context("open service (run elevated)")?;
|
||||||
|
// Best-effort stop: ERROR_SERVICE_NOT_ACTIVE just means restart == start.
|
||||||
|
let _ = svc.stop();
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
let state = svc.query_status().context("query service status")?;
|
||||||
|
if state.current_state == ServiceState::Stopped {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
anyhow::bail!("service did not stop within 30 s");
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
svc.start(&[] as &[&std::ffi::OsStr])
|
||||||
|
.context("start service")?;
|
||||||
|
println!("Restarted service '{SERVICE_NAME}'.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn uninstall() -> Result<()> {
|
fn uninstall() -> Result<()> {
|
||||||
use windows_service::service::ServiceAccess;
|
use windows_service::service::ServiceAccess;
|
||||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
[package]
|
||||||
|
name = "punktfunk-tray"
|
||||||
|
description = "System-tray status icon for the punktfunk streaming host (Windows notification area / Linux StatusNotifierItem)"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "punktfunk-tray"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# Deliberately does NOT depend on punktfunk-host: the tray needs only the service name, the mgmt
|
||||||
|
# port, and the summary JSON shape — a dependency would drag the whole host (FFmpeg, PipeWire, …)
|
||||||
|
# into a 2 MB helper and make it un-buildable standalone. Non-Windows/non-Linux targets build a
|
||||||
|
# stub main (same pattern as the platform-gated clients).
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls(ring) stack and
|
||||||
|
# custom-verifier pattern as the Linux client's library fetch (clients/linux/src/library.rs) —
|
||||||
|
# but ring-only (no default aws-lc-rs provider: it needs a C toolchain per target and the agent
|
||||||
|
# pins the ring provider explicitly anyway).
|
||||||
|
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses.
|
||||||
|
windows-service = "0.7"
|
||||||
|
windows = { version = "0.62", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Graphics_Gdi",
|
||||||
|
"Win32_Security", # CreateMutexW's SECURITY_ATTRIBUTES parameter type
|
||||||
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_Shell",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
# StatusNotifierItem (pure Rust, zbus — the same zbus the host already pulls via ashpd). The tray
|
||||||
|
# is a plain-threads poller, so the blocking API over the small async-io executor (`blocking`
|
||||||
|
# alone is just the wrapper — zbus still needs an executor; no tokio runtime in a tray icon).
|
||||||
|
ksni = { version = "0.3", default-features = false, features = ["async-io", "blocking"] }
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
# Build-time icon embedding (exe icon + the status-variant tray icons), host-gated like the
|
||||||
|
# Windows client's build.rs — cross-builds from Linux CI runners skip it.
|
||||||
|
[target.'cfg(windows)'.build-dependencies]
|
||||||
|
winresource = "0.1"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! Embed the Windows version-info + icon resources into `punktfunk-tray.exe`: ordinal 1 is the
|
||||||
|
//! exe/file icon, ordinals 2–6 are the status-variant tray icons `src/win.rs` loads by id
|
||||||
|
//! (running / stopped / error / streaming / degraded). Same winresource pattern as
|
||||||
|
//! `clients/windows/build.rs`.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// cfg(windows) is the HOST (skips the Linux/macOS workspace stub build); CARGO_CFG_WINDOWS
|
||||||
|
// is the TARGET (mirrors the Windows client's build.rs).
|
||||||
|
#[cfg(windows)]
|
||||||
|
if std::env::var_os("CARGO_CFG_WINDOWS").is_some() {
|
||||||
|
let branding = "../../packaging/windows/branding";
|
||||||
|
let icons = [
|
||||||
|
(format!("{branding}/punktfunk.ico"), "1"),
|
||||||
|
(format!("{branding}/punktfunk-tray-running.ico"), "2"),
|
||||||
|
(format!("{branding}/punktfunk-tray-stopped.ico"), "3"),
|
||||||
|
(format!("{branding}/punktfunk-tray-error.ico"), "4"),
|
||||||
|
(format!("{branding}/punktfunk-tray-streaming.ico"), "5"),
|
||||||
|
(format!("{branding}/punktfunk-tray-degraded.ico"), "6"),
|
||||||
|
];
|
||||||
|
let mut res = winresource::WindowsResource::new();
|
||||||
|
for (path, id) in &icons {
|
||||||
|
println!("cargo:rerun-if-changed={path}");
|
||||||
|
res.set_icon_with_id(path, id);
|
||||||
|
}
|
||||||
|
res.compile().expect("embed windows icon resources");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! Linux tray: a StatusNotifierItem (ksni/zbus) fed by the status poller. The host runs as the
|
||||||
|
//! systemd **user** unit `punktfunk-host.service`, so start/stop/restart are plain
|
||||||
|
//! `systemctl --user` calls — no polkit, no elevation. KDE (the project's primary Linux desktop)
|
||||||
|
//! renders SNI natively; GNOME needs the AppIndicator extension (without it the icon is invisible
|
||||||
|
//! — `--autostart` exits silently rather than erroring at every login).
|
||||||
|
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
|
use crate::status::{self, Poller, TrayStatus};
|
||||||
|
|
||||||
|
/// The tray's D-Bus/menu model. `status` + `web_console` are the mutable state; the poller
|
||||||
|
/// rewrites them via `Handle::update`, which re-emits the SNI properties (icon, tooltip, menu).
|
||||||
|
struct HostTray {
|
||||||
|
status: TrayStatus,
|
||||||
|
web_port: u16,
|
||||||
|
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
||||||
|
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||||
|
web_console: bool,
|
||||||
|
/// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions
|
||||||
|
/// force an immediate re-poll instead of waiting out the cadence.
|
||||||
|
poller: Arc<OnceLock<Poller>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostTray {
|
||||||
|
fn systemctl(&self, verb: &str) {
|
||||||
|
let _ = std::process::Command::new("systemctl")
|
||||||
|
.args(["--user", verb, status::UNIT_NAME])
|
||||||
|
.status();
|
||||||
|
if let Some(p) = self.poller.get() {
|
||||||
|
p.poke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_console(&self) {
|
||||||
|
let url = format!("https://127.0.0.1:{}", self.web_port);
|
||||||
|
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksni::Tray for HostTray {
|
||||||
|
fn id(&self) -> String {
|
||||||
|
"punktfunk-tray".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self) -> String {
|
||||||
|
"punktfunk host".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(&self) -> ksni::Status {
|
||||||
|
match &self.status {
|
||||||
|
TrayStatus::Error(_) => ksni::Status::NeedsAttention,
|
||||||
|
s if s.pairing_attention() => ksni::Status::NeedsAttention,
|
||||||
|
_ => ksni::Status::Active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hicolor theme names (installed by the packages); `icon_pixmap` below is the fallback so a
|
||||||
|
/// `cargo run` from the repo shows an icon too.
|
||||||
|
fn icon_name(&self) -> String {
|
||||||
|
match &self.status {
|
||||||
|
TrayStatus::Running(_) if self.status.is_streaming() => {
|
||||||
|
"punktfunk-tray-streaming".into()
|
||||||
|
}
|
||||||
|
TrayStatus::Running(_) => "punktfunk-tray".into(),
|
||||||
|
TrayStatus::Starting | TrayStatus::Degraded => "punktfunk-tray-degraded".into(),
|
||||||
|
TrayStatus::Error(_) => "punktfunk-tray-error".into(),
|
||||||
|
TrayStatus::Stopped | TrayStatus::NotInstalled => "punktfunk-tray-stopped".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
|
||||||
|
// Same dot palette as scripts/gen-tray-icons.py.
|
||||||
|
let rgb = match &self.status {
|
||||||
|
TrayStatus::Running(_) if self.status.is_streaming() => (0xb4, 0x4c, 0xf0), // violet
|
||||||
|
TrayStatus::Running(_) => (0x2e, 0xcc, 0x71), // green
|
||||||
|
TrayStatus::Starting | TrayStatus::Degraded => (0xf0, 0xa0, 0x30), // amber
|
||||||
|
TrayStatus::Error(_) => (0xe7, 0x4c, 0x3c), // red
|
||||||
|
TrayStatus::Stopped | TrayStatus::NotInstalled => (0x8a, 0x8a, 0x8a), // gray
|
||||||
|
};
|
||||||
|
vec![dot_icon(22, rgb), dot_icon(48, rgb)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_tip(&self) -> ksni::ToolTip {
|
||||||
|
ksni::ToolTip {
|
||||||
|
title: self.status.headline(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
||||||
|
use ksni::menu::*;
|
||||||
|
let running = matches!(
|
||||||
|
self.status,
|
||||||
|
TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded
|
||||||
|
);
|
||||||
|
let startable = matches!(
|
||||||
|
self.status,
|
||||||
|
TrayStatus::Stopped | TrayStatus::Error(_) | TrayStatus::NotInstalled
|
||||||
|
);
|
||||||
|
vec![
|
||||||
|
StandardItem {
|
||||||
|
label: self.status.headline(),
|
||||||
|
enabled: false,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
MenuItem::Separator,
|
||||||
|
StandardItem {
|
||||||
|
label: "Open web console".into(),
|
||||||
|
visible: self.web_console,
|
||||||
|
activate: Box::new(|t: &mut Self| t.open_console()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
StandardItem {
|
||||||
|
label: "Approve pairing request…".into(),
|
||||||
|
visible: self.web_console && self.status.pairing_attention(),
|
||||||
|
activate: Box::new(|t: &mut Self| t.open_console()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
MenuItem::Separator,
|
||||||
|
StandardItem {
|
||||||
|
label: "Start host".into(),
|
||||||
|
visible: startable && !matches!(self.status, TrayStatus::NotInstalled),
|
||||||
|
activate: Box::new(|t: &mut Self| t.systemctl("start")),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
StandardItem {
|
||||||
|
label: "Stop host".into(),
|
||||||
|
visible: running,
|
||||||
|
activate: Box::new(|t: &mut Self| t.systemctl("stop")),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
StandardItem {
|
||||||
|
label: "Restart host".into(),
|
||||||
|
visible: running || matches!(self.status, TrayStatus::Error(_)),
|
||||||
|
activate: Box::new(|t: &mut Self| t.systemctl("restart")),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
MenuItem::Separator,
|
||||||
|
StandardItem {
|
||||||
|
label: "Exit tray".into(),
|
||||||
|
activate: Box::new(|_: &mut Self| std::process::exit(0)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep waiting when the watcher drops (plasmashell restart, GNOME shell reload) — the item
|
||||||
|
/// re-registers when it returns. Only `--autostart` runs get here with SNI truly absent, and
|
||||||
|
/// lingering invisibly is the documented trade-off (see `assume_sni_available` below).
|
||||||
|
fn watcher_offline(&self, _reason: ksni::OfflineReason) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flat antialiased status dot — the pixmap fallback when the hicolor icons aren't installed
|
||||||
|
/// (dev runs from `target/`). ARGB32, network byte order (per the SNI spec).
|
||||||
|
fn dot_icon(size: i32, (r, g, b): (u8, u8, u8)) -> ksni::Icon {
|
||||||
|
let mut data = Vec::with_capacity((size * size * 4) as usize);
|
||||||
|
let center = (size as f32 - 1.0) / 2.0;
|
||||||
|
let radius = size as f32 * 0.38;
|
||||||
|
for y in 0..size {
|
||||||
|
for x in 0..size {
|
||||||
|
let d = ((x as f32 - center).powi(2) + (y as f32 - center).powi(2)).sqrt();
|
||||||
|
// 1 px antialiasing ramp at the rim.
|
||||||
|
let alpha = ((radius - d + 0.5).clamp(0.0, 1.0) * 255.0) as u8;
|
||||||
|
data.extend_from_slice(&[alpha, r, g, b]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksni::Icon {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this user's box run (or intend to run) a punktfunk host? Gates `--autostart` so the
|
||||||
|
/// packaged autostart entry doesn't put an icon in every desktop user's tray.
|
||||||
|
fn host_present() -> bool {
|
||||||
|
if status::punktfunk_config_dir().is_some_and(|d| d.exists()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::process::Command::new("systemctl")
|
||||||
|
.args(["--user", "--quiet", "is-enabled", status::UNIT_NAME])
|
||||||
|
.status()
|
||||||
|
.is_ok_and(|s| s.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tray per session: `flock` on a runtime-dir lockfile (held for the process lifetime).
|
||||||
|
fn acquire_instance_lock() -> Option<std::fs::File> {
|
||||||
|
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.write(true)
|
||||||
|
.open(dir.join("punktfunk-tray.lock"))
|
||||||
|
.ok()?;
|
||||||
|
// SAFETY: `file` is an open, owned fd for the duration of the call; LOCK_NB makes this a
|
||||||
|
// non-blocking advisory lock attempt with no other side effects.
|
||||||
|
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||||
|
(rc == 0).then_some(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
||||||
|
if args.quit {
|
||||||
|
// Windows-only convenience for the uninstaller; nothing to do here.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if args.autostart && !host_present() {
|
||||||
|
return Ok(()); // not a host box — stay out of this user's tray
|
||||||
|
}
|
||||||
|
let Some(_lock) = acquire_instance_lock() else {
|
||||||
|
return Ok(()); // another instance already runs in this session
|
||||||
|
};
|
||||||
|
|
||||||
|
let poller_slot = Arc::new(OnceLock::new());
|
||||||
|
let tray = HostTray {
|
||||||
|
status: TrayStatus::Stopped, // placeholder; the poller fires within its first cycle
|
||||||
|
web_port: args.web_port,
|
||||||
|
web_console: false, // live-probed by the poller within its first cycle
|
||||||
|
poller: poller_slot.clone(),
|
||||||
|
};
|
||||||
|
// Autostart races the desktop (the watcher may register after us) → be lenient and wait for
|
||||||
|
// it. A manual launch should fail loudly instead (e.g. GNOME without the AppIndicator
|
||||||
|
// extension) so the user learns why there is no icon.
|
||||||
|
use ksni::blocking::TrayMethods;
|
||||||
|
let handle = match tray.assume_sni_available(args.autostart).spawn() {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) if args.autostart => {
|
||||||
|
eprintln!("punktfunk-tray: no StatusNotifier host ({e}); exiting");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => anyhow::bail!(
|
||||||
|
"no StatusNotifier tray available ({e}) — on GNOME, install the AppIndicator extension"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dead = Arc::new(AtomicBool::new(false));
|
||||||
|
let dead_flag = dead.clone();
|
||||||
|
let update_handle = handle.clone();
|
||||||
|
let poller = Poller::spawn(
|
||||||
|
args.mgmt_addr.clone(),
|
||||||
|
args.mgmt_port,
|
||||||
|
args.web_port,
|
||||||
|
Box::new(move |st, console_up| {
|
||||||
|
let updated = update_handle.update(|t: &mut HostTray| {
|
||||||
|
t.status = st;
|
||||||
|
t.web_console = console_up;
|
||||||
|
});
|
||||||
|
if updated.is_none() {
|
||||||
|
dead_flag.store(true, Ordering::SeqCst); // tray service shut down
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let _ = poller_slot.set(poller);
|
||||||
|
|
||||||
|
// The SNI service runs on its own thread; park here until it dies (shell logout etc.).
|
||||||
|
while !dead.load(Ordering::SeqCst) && !handle.is_closed() {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! punktfunk-tray — a small per-user system-tray companion for the punktfunk host service.
|
||||||
|
//!
|
||||||
|
//! Shows at a glance whether the host is running / stopped / degraded / failed (no more digging
|
||||||
|
//! through logs after a reboot or an update), and offers the common one-click actions: open the
|
||||||
|
//! web console, start/stop/restart the service (UAC-elevated per action on Windows,
|
||||||
|
//! `systemctl --user` on Linux), review a pending pairing request, exit.
|
||||||
|
//!
|
||||||
|
//! Status comes from two sources, service manager FIRST (a fake listener on the mgmt port can
|
||||||
|
//! never make a stopped service look running): the SCM / systemd user unit for the process state,
|
||||||
|
//! then the host's loopback-only unauthenticated `GET /api/v1/local/summary` for the streaming
|
||||||
|
//! details. Windows-subsystem binary — a console exe in the HKLM Run key would flash a terminal
|
||||||
|
//! window at every sign-in.
|
||||||
|
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux;
|
||||||
|
#[cfg(any(windows, target_os = "linux"))]
|
||||||
|
mod status;
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod win;
|
||||||
|
|
||||||
|
/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the
|
||||||
|
/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is
|
||||||
|
/// DACL-locked to SYSTEM/Administrators), so an operator who moved `--mgmt-bind` adjusts the
|
||||||
|
/// autostart command line instead.
|
||||||
|
pub struct Args {
|
||||||
|
/// Ask an already-running tray instance to exit (Windows; used by the uninstaller).
|
||||||
|
pub quit: bool,
|
||||||
|
/// Launched from the desktop autostart entry: exit silently when this box doesn't run a host
|
||||||
|
/// (Linux; the package installs the autostart file for every desktop user).
|
||||||
|
pub autostart: bool,
|
||||||
|
/// Management API address to poll (loopback only; the summary route rejects anything else).
|
||||||
|
pub mgmt_addr: String,
|
||||||
|
pub mgmt_port: u16,
|
||||||
|
/// Web console port for the "Open web console" action.
|
||||||
|
pub web_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Args {
|
||||||
|
fn default() -> Self {
|
||||||
|
Args {
|
||||||
|
quit: false,
|
||||||
|
autostart: false,
|
||||||
|
mgmt_addr: "127.0.0.1".into(),
|
||||||
|
mgmt_port: 47990,
|
||||||
|
web_port: 47992,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> anyhow::Result<Args> {
|
||||||
|
let mut args = Args::default();
|
||||||
|
let mut it = std::env::args().skip(1);
|
||||||
|
while let Some(a) = it.next() {
|
||||||
|
let mut value = |flag: &str| {
|
||||||
|
it.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
|
||||||
|
};
|
||||||
|
match a.as_str() {
|
||||||
|
"--quit" => args.quit = true,
|
||||||
|
"--autostart" => args.autostart = true,
|
||||||
|
"--mgmt-addr" => args.mgmt_addr = value("--mgmt-addr")?,
|
||||||
|
"--mgmt-port" => args.mgmt_port = value("--mgmt-port")?.parse()?,
|
||||||
|
"--web-port" => args.web_port = value("--web-port")?.parse()?,
|
||||||
|
"--version" | "-V" => {
|
||||||
|
println!("punktfunk-tray {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
other => anyhow::bail!(
|
||||||
|
"unknown argument '{other}'\n\nUSAGE:\n punktfunk-tray [--autostart] [--quit] \
|
||||||
|
[--mgmt-addr <IP>] [--mgmt-port <N>] [--web-port <N>]"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let args = parse_args()?;
|
||||||
|
run(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn run(args: Args) -> anyhow::Result<()> {
|
||||||
|
win::run(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn run(args: Args) -> anyhow::Result<()> {
|
||||||
|
linux::run(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(windows, target_os = "linux")))]
|
||||||
|
fn run(_args: Args) -> anyhow::Result<()> {
|
||||||
|
// Workspace-stub build (macOS CI etc.) — the tray ships on Windows and Linux only.
|
||||||
|
anyhow::bail!("punktfunk-tray supports Windows and Linux hosts only")
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
//! Host status model + the poller thread feeding the platform tray implementations.
|
||||||
|
//!
|
||||||
|
//! Two sources, service manager FIRST: the SCM (Windows) / systemd user unit (Linux) decides
|
||||||
|
//! stopped-vs-running — a malicious local process squatting the mgmt port while the service is
|
||||||
|
//! down can never make the tray say Running. Only when the service manager reports Running does
|
||||||
|
//! the poller consult the host's loopback-only `GET /api/v1/local/summary` for streaming detail.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// What the service manager reports for the host service.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum ServiceState {
|
||||||
|
NotInstalled,
|
||||||
|
Stopped,
|
||||||
|
StartPending,
|
||||||
|
StopPending,
|
||||||
|
Running,
|
||||||
|
/// Linux `ActiveState=failed` (with the sub-state), or a Windows stop with a failure exit code.
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/local/summary` — the non-sensitive counts/booleans the host serves to loopback
|
||||||
|
/// peers without authentication (mgmt.rs `LocalSummary`). Unknown fields are ignored so a newer
|
||||||
|
/// host can grow the summary without breaking an older tray.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
|
||||||
|
pub struct Summary {
|
||||||
|
pub version: String,
|
||||||
|
pub video_streaming: bool,
|
||||||
|
pub audio_streaming: bool,
|
||||||
|
pub session: Option<SessionInfo>,
|
||||||
|
pub paired_clients: u32,
|
||||||
|
pub native_paired_clients: u32,
|
||||||
|
pub pin_pending: bool,
|
||||||
|
pub pending_approvals: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fps: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the icon shows.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum TrayStatus {
|
||||||
|
NotInstalled,
|
||||||
|
Stopped,
|
||||||
|
/// Service starting, or running with the mgmt API not answering yet (within [`START_GRACE`]).
|
||||||
|
Starting,
|
||||||
|
Running(Summary),
|
||||||
|
/// Service running but the summary unreachable past the grace period — amber, not red: a
|
||||||
|
/// custom `PUNKTFUNK_HOST_CMD` (no mgmt API) or a relocated `--mgmt-bind` is legitimate.
|
||||||
|
Degraded,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrayStatus {
|
||||||
|
/// One-line headline for the tooltip / the disabled menu header.
|
||||||
|
pub fn headline(&self) -> String {
|
||||||
|
match self {
|
||||||
|
TrayStatus::NotInstalled => "punktfunk host — not installed".into(),
|
||||||
|
TrayStatus::Stopped => "punktfunk host — stopped".into(),
|
||||||
|
TrayStatus::Starting => "punktfunk host — starting…".into(),
|
||||||
|
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
||||||
|
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
||||||
|
TrayStatus::Running(s) => match (&s.session, s.video_streaming) {
|
||||||
|
(Some(sess), true) => format!(
|
||||||
|
"punktfunk host {} — streaming {}×{}@{}",
|
||||||
|
s.version, sess.width, sess.height, sess.fps
|
||||||
|
),
|
||||||
|
(_, true) => format!("punktfunk host {} — streaming", s.version),
|
||||||
|
_ => format!("punktfunk host {} — idle", s.version),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_streaming(&self) -> bool {
|
||||||
|
matches!(self, TrayStatus::Running(s) if s.video_streaming)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
|
||||||
|
pub fn pairing_attention(&self) -> bool {
|
||||||
|
matches!(self, TrayStatus::Running(s) if s.pin_pending || s.pending_approvals > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a running service may leave the summary unreachable before Starting turns Degraded.
|
||||||
|
/// Also re-applied mid-life: the SYSTEM supervisor relaunching a crashed host child looks like
|
||||||
|
/// "running, briefly unreachable" — that shows as Starting again, not an alarming flicker to red.
|
||||||
|
pub const START_GRACE: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Pure status mapping (unit-tested): service-manager state first, summary second, grace third.
|
||||||
|
pub fn map_status(svc: &ServiceState, summary: Option<Summary>, grace_expired: bool) -> TrayStatus {
|
||||||
|
match svc {
|
||||||
|
ServiceState::NotInstalled => TrayStatus::NotInstalled,
|
||||||
|
ServiceState::Stopped | ServiceState::StopPending => TrayStatus::Stopped,
|
||||||
|
ServiceState::StartPending => TrayStatus::Starting,
|
||||||
|
ServiceState::Failed(e) => TrayStatus::Error(e.clone()),
|
||||||
|
ServiceState::Running => match summary {
|
||||||
|
Some(s) => TrayStatus::Running(s),
|
||||||
|
None if !grace_expired => TrayStatus::Starting,
|
||||||
|
None => TrayStatus::Degraded,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Poller ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct Poller {
|
||||||
|
shared: Arc<Shared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Shared {
|
||||||
|
poked: Mutex<bool>,
|
||||||
|
cv: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Poller {
|
||||||
|
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
|
||||||
|
/// either changes. `console_up` is a live loopback probe of the web console on `web_port` —
|
||||||
|
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run
|
||||||
|
/// from a repo checkout, and shows a dead entry while an installed console is still starting).
|
||||||
|
pub fn spawn(
|
||||||
|
mgmt_addr: String,
|
||||||
|
mgmt_port: u16,
|
||||||
|
web_port: u16,
|
||||||
|
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||||
|
) -> Poller {
|
||||||
|
let shared = Arc::new(Shared {
|
||||||
|
poked: Mutex::new(false),
|
||||||
|
cv: Condvar::new(),
|
||||||
|
});
|
||||||
|
let thread_shared = shared.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("status-poll".into())
|
||||||
|
.spawn(move || poll_loop(&thread_shared, &mgmt_addr, mgmt_port, web_port, on_change))
|
||||||
|
.expect("spawn status-poll thread");
|
||||||
|
Poller { shared }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force an immediate re-poll (right after a start/stop/restart menu action).
|
||||||
|
pub fn poke(&self) {
|
||||||
|
*self.shared.poked.lock().unwrap() = true;
|
||||||
|
self.shared.cv.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_loop(
|
||||||
|
shared: &Shared,
|
||||||
|
mgmt_addr: &str,
|
||||||
|
mgmt_port: u16,
|
||||||
|
web_port: u16,
|
||||||
|
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||||
|
) {
|
||||||
|
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
||||||
|
let url = if mgmt_addr.contains(':') {
|
||||||
|
format!("https://[{mgmt_addr}]:{mgmt_port}/api/v1/local/summary")
|
||||||
|
} else {
|
||||||
|
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
|
||||||
|
};
|
||||||
|
let console_url = format!("https://127.0.0.1:{web_port}/");
|
||||||
|
let agent = agent(load_pin());
|
||||||
|
let mut last: Option<(TrayStatus, bool)> = None;
|
||||||
|
// When the summary became unreachable while the service was running (grace anchor).
|
||||||
|
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
|
||||||
|
let mut unreachable_since: Option<Instant> = None;
|
||||||
|
loop {
|
||||||
|
let svc = probe_service();
|
||||||
|
let summary = if svc == ServiceState::Running {
|
||||||
|
let s = fetch_summary(&agent, &url);
|
||||||
|
match s {
|
||||||
|
Some(_) => unreachable_since = None,
|
||||||
|
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
unreachable_since = None;
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
|
||||||
|
let status = map_status(&svc, summary, grace_expired);
|
||||||
|
let console_up = probe_console(&agent, &console_url);
|
||||||
|
if last.as_ref() != Some(&(status.clone(), console_up)) {
|
||||||
|
on_change(status.clone(), console_up);
|
||||||
|
last = Some((status, console_up));
|
||||||
|
}
|
||||||
|
// 3 s while there is anything to watch; back off when the box just doesn't run a host.
|
||||||
|
let cadence = match last.as_ref().map(|(s, _)| s) {
|
||||||
|
Some(TrayStatus::Stopped) | Some(TrayStatus::NotInstalled) => Duration::from_secs(10),
|
||||||
|
_ => Duration::from_secs(3),
|
||||||
|
};
|
||||||
|
let mut poked = shared.poked.lock().unwrap();
|
||||||
|
if !*poked {
|
||||||
|
(poked, _) = shared.cv.wait_timeout(poked, cadence).unwrap();
|
||||||
|
}
|
||||||
|
*poked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the web console answering on loopback? Any HTTP response (incl. the login redirect / 401)
|
||||||
|
/// counts as up — only a transport failure (nothing listening, TLS handshake dead) means down.
|
||||||
|
fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
|
||||||
|
match agent.get(url).call() {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(ureq::Error::Status(..)) => true,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary fetch (loopback HTTPS) ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
|
||||||
|
let body = agent.get(url).call().ok()?.into_string().ok()?;
|
||||||
|
serde_json::from_str(&body).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host identity cert's SHA-256, when `cert.pem` is readable (Linux: same-user file). On
|
||||||
|
/// Windows the file is SYSTEM/Administrators-DACL'd, so the per-user tray can't pin — `None` =
|
||||||
|
/// accept any cert. That is acceptable here: the connection is loopback, carries no credentials,
|
||||||
|
/// and only *reads* non-sensitive data; stopped-vs-running is decided by the service manager, so
|
||||||
|
/// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box.
|
||||||
|
fn load_pin() -> Option<[u8; 32]> {
|
||||||
|
use rustls::pki_types::pem::PemObject;
|
||||||
|
use sha2::Digest;
|
||||||
|
let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?;
|
||||||
|
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?;
|
||||||
|
Some(sha2::Sha256::digest(der.as_ref()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host's config dir, mirroring `gamestream::config_dir()` without linking the host crate:
|
||||||
|
/// `PUNKTFUNK_CONFIG_DIR` override, else `$XDG_CONFIG_HOME`/`~/.config` + `punktfunk` (Linux).
|
||||||
|
/// `None` on Windows — everything the tray would read there is SYSTEM/Admins-DACL'd anyway.
|
||||||
|
pub fn punktfunk_config_dir() -> Option<std::path::PathBuf> {
|
||||||
|
if let Some(d) = std::env::var_os("PUNKTFUNK_CONFIG_DIR") {
|
||||||
|
if !d.is_empty() {
|
||||||
|
return Some(std::path::PathBuf::from(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
if !x.is_empty() {
|
||||||
|
return Some(std::path::PathBuf::from(x).join("punktfunk"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::env::var_os("HOME").map(|h| {
|
||||||
|
std::path::PathBuf::from(h)
|
||||||
|
.join(".config")
|
||||||
|
.join("punktfunk")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sync HTTPS agent over the same rustls(ring) stack the rest of the workspace uses, with a
|
||||||
|
/// pin-or-accept-any verifier (the Linux client's `PinVerify` pattern, `library.rs`).
|
||||||
|
fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
|
||||||
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||||
|
let cfg = rustls::ClientConfig::builder_with_provider(provider)
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.expect("rustls default protocol versions")
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }))
|
||||||
|
.with_no_client_auth();
|
||||||
|
ureq::AgentBuilder::new()
|
||||||
|
.tls_config(Arc::new(cfg))
|
||||||
|
.timeout_connect(Duration::from_secs(2))
|
||||||
|
.timeout(Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trust = the SHA-256 of the host's self-signed leaf (or any cert when un-pinned). Handshake
|
||||||
|
/// signatures are still verified for real — CertificateVerify proves the peer holds the key.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PinVerify {
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_ocsp: &[u8],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
use sha2::Digest;
|
||||||
|
if let Some(expected) = self.pin {
|
||||||
|
let fp: [u8; 32] = sha2::Sha256::digest(end_entity.as_ref()).into();
|
||||||
|
if fp != expected {
|
||||||
|
return Err(rustls::Error::InvalidCertificate(
|
||||||
|
rustls::CertificateError::ApplicationVerificationFailure,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The SCM name registered by `punktfunk-host service install` (windows/service.rs SERVICE_NAME).
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub const SERVICE_NAME: &str = "PunktfunkHost";
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn probe_service() -> ServiceState {
|
||||||
|
use windows_service::service::{ServiceAccess, ServiceExitCode, ServiceState as Scm};
|
||||||
|
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||||
|
// CONNECT + QUERY_STATUS work unprivileged. Re-opened every poll on purpose: a reinstall
|
||||||
|
// (delete + create) invalidates old handles, and this picks the new service up within a poll.
|
||||||
|
let Ok(manager) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||||
|
else {
|
||||||
|
return ServiceState::NotInstalled;
|
||||||
|
};
|
||||||
|
let Ok(svc) = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) else {
|
||||||
|
return ServiceState::NotInstalled; // ERROR_SERVICE_DOES_NOT_EXIST et al.
|
||||||
|
};
|
||||||
|
let Ok(status) = svc.query_status() else {
|
||||||
|
return ServiceState::NotInstalled;
|
||||||
|
};
|
||||||
|
match status.current_state {
|
||||||
|
Scm::StartPending => ServiceState::StartPending,
|
||||||
|
Scm::StopPending => ServiceState::StopPending,
|
||||||
|
Scm::Running | Scm::ContinuePending | Scm::PausePending | Scm::Paused => {
|
||||||
|
ServiceState::Running
|
||||||
|
}
|
||||||
|
Scm::Stopped => match status.exit_code {
|
||||||
|
// 0 = clean; 1077 = never started since boot (ERROR_SERVICE_NEVER_HAS_BEEN_RUN? no —
|
||||||
|
// "no attempts to start have been made"): both are an ordinary Stopped, not a failure.
|
||||||
|
ServiceExitCode::Win32(0) | ServiceExitCode::Win32(1077) => ServiceState::Stopped,
|
||||||
|
ServiceExitCode::Win32(code) => ServiceState::Failed(format!("exit code {code}")),
|
||||||
|
ServiceExitCode::ServiceSpecific(code) => {
|
||||||
|
ServiceState::Failed(format!("service error {code}"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The systemd user unit the Linux packages install (scripts/punktfunk-host.service).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub const UNIT_NAME: &str = "punktfunk-host.service";
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn probe_service() -> ServiceState {
|
||||||
|
// `systemctl show` exits 0 even for unknown units (LoadState=not-found) — parse, don't rely
|
||||||
|
// on the exit code.
|
||||||
|
let Ok(out) = std::process::Command::new("systemctl")
|
||||||
|
.args([
|
||||||
|
"--user",
|
||||||
|
"show",
|
||||||
|
UNIT_NAME,
|
||||||
|
"--property=LoadState,ActiveState,SubState",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return ServiceState::NotInstalled; // no systemctl → nothing to watch
|
||||||
|
};
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let prop = |key: &str| {
|
||||||
|
text.lines()
|
||||||
|
.find_map(|l| l.strip_prefix(key)?.strip_prefix('='))
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
if prop("LoadState") == "not-found" {
|
||||||
|
return ServiceState::NotInstalled;
|
||||||
|
}
|
||||||
|
match prop("ActiveState").as_str() {
|
||||||
|
"active" | "reloading" => ServiceState::Running,
|
||||||
|
"activating" => ServiceState::StartPending,
|
||||||
|
"deactivating" => ServiceState::StopPending,
|
||||||
|
"failed" => ServiceState::Failed(prop("SubState")),
|
||||||
|
_ => ServiceState::Stopped, // "inactive" and anything new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn summary(streaming: bool) -> Summary {
|
||||||
|
Summary {
|
||||||
|
version: "0.5.1".into(),
|
||||||
|
video_streaming: streaming,
|
||||||
|
audio_streaming: streaming,
|
||||||
|
session: streaming.then_some(SessionInfo {
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
fps: 120,
|
||||||
|
}),
|
||||||
|
paired_clients: 1,
|
||||||
|
native_paired_clients: 2,
|
||||||
|
pin_pending: false,
|
||||||
|
pending_approvals: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full (service state × summary × grace) table.
|
||||||
|
#[test]
|
||||||
|
fn status_mapping_table() {
|
||||||
|
use ServiceState as S;
|
||||||
|
use TrayStatus as T;
|
||||||
|
let cases: Vec<(S, Option<Summary>, bool, T)> = vec![
|
||||||
|
(S::NotInstalled, None, false, T::NotInstalled),
|
||||||
|
(S::Stopped, None, false, T::Stopped),
|
||||||
|
(S::StopPending, None, false, T::Stopped),
|
||||||
|
(S::StartPending, None, false, T::Starting),
|
||||||
|
(
|
||||||
|
S::Failed("code 3".into()),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
T::Error("code 3".into()),
|
||||||
|
),
|
||||||
|
// Running + summary → Running regardless of grace.
|
||||||
|
(
|
||||||
|
S::Running,
|
||||||
|
Some(summary(false)),
|
||||||
|
true,
|
||||||
|
T::Running(summary(false)),
|
||||||
|
),
|
||||||
|
// Running + unreachable: Starting within grace, Degraded past it.
|
||||||
|
(S::Running, None, false, T::Starting),
|
||||||
|
(S::Running, None, true, T::Degraded),
|
||||||
|
// A summary while the SCM says Stopped is impossible by construction (the poller only
|
||||||
|
// fetches when Running) — but the mapping must still trust the service manager.
|
||||||
|
(S::Stopped, Some(summary(true)), false, T::Stopped),
|
||||||
|
];
|
||||||
|
for (svc, sum, grace, want) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
map_status(&svc, sum.clone(), grace),
|
||||||
|
want,
|
||||||
|
"{svc:?} {sum:?} grace={grace}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn headline_shows_session_and_reason() {
|
||||||
|
assert_eq!(
|
||||||
|
TrayStatus::Running(summary(true)).headline(),
|
||||||
|
"punktfunk host 0.5.1 — streaming 2560×1440@120"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
TrayStatus::Running(summary(false)).headline(),
|
||||||
|
"punktfunk host 0.5.1 — idle"
|
||||||
|
);
|
||||||
|
assert!(TrayStatus::Error("exit code 3".into())
|
||||||
|
.headline()
|
||||||
|
.contains("exit code 3"));
|
||||||
|
assert!(TrayStatus::Degraded
|
||||||
|
.headline()
|
||||||
|
.contains("status unavailable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pairing_attention_flags() {
|
||||||
|
let mut s = summary(false);
|
||||||
|
assert!(!TrayStatus::Running(s.clone()).pairing_attention());
|
||||||
|
s.pending_approvals = 1;
|
||||||
|
assert!(TrayStatus::Running(s.clone()).pairing_attention());
|
||||||
|
s.pending_approvals = 0;
|
||||||
|
s.pin_pending = true;
|
||||||
|
assert!(TrayStatus::Running(s).pairing_attention());
|
||||||
|
assert!(!TrayStatus::Degraded.pairing_attention());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
//! Windows tray: a hidden top-level window + `Shell_NotifyIconW`, fed by the status poller.
|
||||||
|
//!
|
||||||
|
//! The host service (`PunktfunkHost`, LocalSystem) supervises from session 0 and its `serve`
|
||||||
|
//! child runs as SYSTEM — neither can own a per-user tray icon, so this is a separate small
|
||||||
|
//! process the installer puts in the HKLM `Run` key (one instance per interactive session,
|
||||||
|
//! enforced by a `Local\` mutex). Start/Stop/Restart open one UAC consent prompt each
|
||||||
|
//! (`ShellExecuteW "runas"` on `punktfunk-host.exe service …`) — service control is deliberately
|
||||||
|
//! left admin-gated rather than DACL-opened to every local user.
|
||||||
|
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use windows::core::{w, PCWSTR};
|
||||||
|
use windows::Win32::Foundation::{
|
||||||
|
GetLastError, ERROR_ALREADY_EXISTS, HWND, LPARAM, LRESULT, WPARAM,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
|
use windows::Win32::System::Threading::CreateMutexW;
|
||||||
|
use windows::Win32::UI::Shell::{
|
||||||
|
ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIM_ADD,
|
||||||
|
NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4,
|
||||||
|
};
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow,
|
||||||
|
DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadIconW, PostMessageW,
|
||||||
|
PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow,
|
||||||
|
SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, MF_GRAYED, MF_SEPARATOR,
|
||||||
|
MF_STRING, MSG, SW_HIDE, SW_SHOWNORMAL, TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE,
|
||||||
|
WM_APP, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WNDCLASSW,
|
||||||
|
WS_OVERLAPPED,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::status::{Poller, TrayStatus};
|
||||||
|
|
||||||
|
/// Keyboard "select" on the icon (Enter/Space) — `NIN_SELECT | NINF_KEY`; the windows crate
|
||||||
|
/// exports only NIN_SELECT.
|
||||||
|
const NIN_KEYSELECT: u32 = NIN_SELECT | 0x1;
|
||||||
|
|
||||||
|
/// Posted by the poller thread when the status changed (never touch TLS on the UI thread).
|
||||||
|
const WMAPP_STATUS: u32 = WM_APP + 2;
|
||||||
|
/// The notify-icon callback message (NOTIFYICON_VERSION_4 semantics).
|
||||||
|
const WMAPP_NOTIFYCALLBACK: u32 = WM_APP + 1;
|
||||||
|
|
||||||
|
// Menu command ids (WM_COMMAND LOWORD(wParam)).
|
||||||
|
const IDM_HEADER: usize = 0x0100; // disabled status line
|
||||||
|
const IDM_OPEN_WEB: usize = 0x0101;
|
||||||
|
const IDM_START: usize = 0x0102;
|
||||||
|
const IDM_STOP: usize = 0x0103;
|
||||||
|
const IDM_RESTART: usize = 0x0104;
|
||||||
|
const IDM_LOGS: usize = 0x0105;
|
||||||
|
const IDM_EXIT: usize = 0x0106;
|
||||||
|
const IDM_PAIRING: usize = 0x0107;
|
||||||
|
|
||||||
|
/// Icon resource ordinals (embedded by build.rs).
|
||||||
|
fn icon_ordinal(status: &TrayStatus) -> u16 {
|
||||||
|
match status {
|
||||||
|
TrayStatus::Running(_) if status.is_streaming() => 5,
|
||||||
|
TrayStatus::Running(_) => 2,
|
||||||
|
TrayStatus::Stopped | TrayStatus::NotInstalled => 3,
|
||||||
|
TrayStatus::Error(_) => 4,
|
||||||
|
TrayStatus::Starting | TrayStatus::Degraded => 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global tray state — a tray has exactly one window and one wndproc, which cannot carry a
|
||||||
|
/// closure environment, so the state lives in a `OnceLock` set before window creation.
|
||||||
|
struct App {
|
||||||
|
hwnd: AtomicIsize,
|
||||||
|
status: Mutex<TrayStatus>,
|
||||||
|
poller: OnceLock<Poller>,
|
||||||
|
/// `TaskbarCreated` broadcast id — Explorer restarted, re-add the icon.
|
||||||
|
taskbar_created: u32,
|
||||||
|
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
|
||||||
|
host_exe: Option<std::path::PathBuf>,
|
||||||
|
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
||||||
|
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||||
|
web_console: AtomicBool,
|
||||||
|
web_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
static APP: OnceLock<App> = OnceLock::new();
|
||||||
|
|
||||||
|
fn app() -> &'static App {
|
||||||
|
APP.get().expect("APP initialized before window creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_wide(s: &str) -> Vec<u16> {
|
||||||
|
std::ffi::OsStr::new(s).encode_wide().chain([0]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort log for a windows-subsystem process (no stderr): `%LOCALAPPDATA%\punktfunk\tray.log`.
|
||||||
|
fn log(msg: &str) {
|
||||||
|
let Some(base) = std::env::var_os("LOCALAPPDATA") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dir = std::path::PathBuf::from(base).join("punktfunk");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(dir.join("tray.log"))
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = writeln!(f, "{msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
||||||
|
let _ = args.autostart; // Linux-only flag, accepted for a uniform command line
|
||||||
|
if args.quit {
|
||||||
|
return quit_existing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// One tray per session: `Local\` scopes the mutex to this logon session, so fast-user-switched
|
||||||
|
// sessions each keep their own icon. Handle deliberately leaked (held for the process life).
|
||||||
|
// SAFETY: CreateMutexW with a valid nul-terminated name and no security attributes; the
|
||||||
|
// returned handle is never closed (process-lifetime singleton guard).
|
||||||
|
let already = unsafe {
|
||||||
|
match CreateMutexW(None, false, w!("Local\\PunktfunkTray")) {
|
||||||
|
Ok(_) => GetLastError() == ERROR_ALREADY_EXISTS,
|
||||||
|
Err(_) => false, // can't tell — carry on rather than losing the icon
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if already {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let host_exe = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|d| d.join("punktfunk-host.exe")))
|
||||||
|
.filter(|p| p.exists());
|
||||||
|
|
||||||
|
// SAFETY: RegisterWindowMessageW with a static nul-terminated literal.
|
||||||
|
let taskbar_created = unsafe { RegisterWindowMessageW(w!("TaskbarCreated")) };
|
||||||
|
APP.set(App {
|
||||||
|
hwnd: AtomicIsize::new(0),
|
||||||
|
status: Mutex::new(TrayStatus::Stopped),
|
||||||
|
poller: OnceLock::new(),
|
||||||
|
taskbar_created,
|
||||||
|
host_exe,
|
||||||
|
web_console: AtomicBool::new(false), // live-probed by the poller within its first cycle
|
||||||
|
web_port: args.web_port,
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
.expect("run() is called once");
|
||||||
|
|
||||||
|
// Hidden top-level window (NOT message-only — those never receive the TaskbarCreated
|
||||||
|
// broadcast, which is how the icon survives an Explorer restart).
|
||||||
|
// SAFETY: standard window-class registration + creation; the class name literal outlives the
|
||||||
|
// call, wndproc is a valid extern "system" fn, and the window is created on this thread which
|
||||||
|
// then runs the message loop.
|
||||||
|
let hwnd = unsafe {
|
||||||
|
let hinstance = GetModuleHandleW(None)?;
|
||||||
|
let class = WNDCLASSW {
|
||||||
|
lpfnWndProc: Some(wndproc),
|
||||||
|
hInstance: hinstance.into(),
|
||||||
|
lpszClassName: w!("PunktfunkTrayWindow"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if RegisterClassW(&class) == 0 {
|
||||||
|
anyhow::bail!("RegisterClassW failed: {:?}", GetLastError());
|
||||||
|
}
|
||||||
|
CreateWindowExW(
|
||||||
|
WINDOW_EX_STYLE(0),
|
||||||
|
w!("PunktfunkTrayWindow"),
|
||||||
|
w!("punktfunk tray"),
|
||||||
|
WS_OVERLAPPED,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(hinstance.into()),
|
||||||
|
None,
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
app().hwnd.store(hwnd.0 as isize, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// First NIM_ADD retried across the logon race (the taskbar may not exist yet at sign-in).
|
||||||
|
let mut added = false;
|
||||||
|
for _ in 0..10 {
|
||||||
|
if update_icon(hwnd, true) {
|
||||||
|
added = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
if !added {
|
||||||
|
log("Shell_NotifyIconW(NIM_ADD) kept failing — no taskbar?");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The poller owns all network/SCM I/O; it only posts a message here.
|
||||||
|
let poller = Poller::spawn(
|
||||||
|
args.mgmt_addr.clone(),
|
||||||
|
args.mgmt_port,
|
||||||
|
args.web_port,
|
||||||
|
Box::new(move |st, console_up| {
|
||||||
|
*app().status.lock().unwrap() = st;
|
||||||
|
app().web_console.store(console_up, Ordering::SeqCst);
|
||||||
|
let hwnd = HWND(app().hwnd.load(Ordering::SeqCst) as *mut _);
|
||||||
|
// SAFETY: PostMessageW is documented thread-safe; a stale/destroyed hwnd fails
|
||||||
|
// harmlessly with an error we ignore.
|
||||||
|
unsafe {
|
||||||
|
let _ = PostMessageW(Some(hwnd), WMAPP_STATUS, WPARAM(0), LPARAM(0));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let _ = app().poller.set(poller);
|
||||||
|
|
||||||
|
// SAFETY: classic message pump on the window's owning thread.
|
||||||
|
unsafe {
|
||||||
|
let mut msg = MSG::default();
|
||||||
|
while GetMessageW(&mut msg, None, 0, 0).into() {
|
||||||
|
let _ = TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--quit`: ask a running instance (this session) to exit — used by the uninstaller before file
|
||||||
|
/// deletion. High-IL callers may message a medium-IL window (UIPI blocks only low→high).
|
||||||
|
fn quit_existing() -> anyhow::Result<()> {
|
||||||
|
// SAFETY: FindWindowW/PostMessageW on a class-name literal; both fail harmlessly when no
|
||||||
|
// instance is running.
|
||||||
|
unsafe {
|
||||||
|
if let Ok(hwnd) = FindWindowW(w!("PunktfunkTrayWindow"), PCWSTR::null()) {
|
||||||
|
let _ = PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build/refresh the notify icon from the current status. Returns false when the shell rejected
|
||||||
|
/// the call (no taskbar yet).
|
||||||
|
fn update_icon(hwnd: HWND, add: bool) -> bool {
|
||||||
|
let status = app().status.lock().unwrap().clone();
|
||||||
|
let mut nid = NOTIFYICONDATAW {
|
||||||
|
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
|
||||||
|
hWnd: hwnd,
|
||||||
|
uID: 1,
|
||||||
|
uFlags: NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP,
|
||||||
|
uCallbackMessage: WMAPP_NOTIFYCALLBACK,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: LoadIconW by ordinal from this exe's embedded resources (build.rs); the ordinal is
|
||||||
|
// one of the ids compiled in, and a failure falls back to a null icon rather than UB.
|
||||||
|
nid.hIcon = unsafe {
|
||||||
|
LoadIconW(
|
||||||
|
Some(GetModuleHandleW(None).unwrap_or_default().into()),
|
||||||
|
PCWSTR(icon_ordinal(&status) as usize as *const u16),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.unwrap_or(HICON(std::ptr::null_mut()));
|
||||||
|
// Tooltip: truncate to the szTip capacity (127 UTF-16 units + nul).
|
||||||
|
let tip = to_wide(&status.headline());
|
||||||
|
let n = tip.len().min(nid.szTip.len() - 1);
|
||||||
|
nid.szTip[..n].copy_from_slice(&tip[..n]);
|
||||||
|
|
||||||
|
// SAFETY: nid is fully initialized with a correct cbSize; NIM_* calls only read it.
|
||||||
|
unsafe {
|
||||||
|
if add {
|
||||||
|
if !Shell_NotifyIconW(NIM_ADD, &nid).as_bool() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut v = nid;
|
||||||
|
v.Anonymous.uVersion = NOTIFYICON_VERSION_4;
|
||||||
|
let _ = Shell_NotifyIconW(NIM_SETVERSION, &v);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
if !Shell_NotifyIconW(NIM_MODIFY, &nid).as_bool() {
|
||||||
|
// Icon vanished (Explorer crash we missed) — re-add.
|
||||||
|
return update_icon(hwnd, true);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The right-click menu, rebuilt from the live status each time.
|
||||||
|
fn show_menu(hwnd: HWND) {
|
||||||
|
let status = app().status.lock().unwrap().clone();
|
||||||
|
let running = matches!(
|
||||||
|
status,
|
||||||
|
TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded
|
||||||
|
);
|
||||||
|
let startable = matches!(status, TrayStatus::Stopped | TrayStatus::Error(_));
|
||||||
|
let can_control = app().host_exe.is_some();
|
||||||
|
|
||||||
|
// SAFETY: menu handle created and destroyed here; AppendMenuW copies the item strings, whose
|
||||||
|
// wide buffers outlive each call. TrackPopupMenuEx requires the foreground quirk handled
|
||||||
|
// below (SetForegroundWindow before, WM_NULL after) per the Shell_NotifyIcon docs.
|
||||||
|
unsafe {
|
||||||
|
let Ok(menu) = CreatePopupMenu() else { return };
|
||||||
|
let add = |id: usize, text: &str, grayed: bool| {
|
||||||
|
let wide = to_wide(text);
|
||||||
|
let flags = if grayed {
|
||||||
|
MF_STRING | MF_GRAYED
|
||||||
|
} else {
|
||||||
|
MF_STRING
|
||||||
|
};
|
||||||
|
let _ = AppendMenuW(menu, flags, id, PCWSTR(wide.as_ptr()));
|
||||||
|
};
|
||||||
|
add(IDM_HEADER, &status.headline(), true);
|
||||||
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||||
|
if app().web_console.load(Ordering::SeqCst) {
|
||||||
|
add(IDM_OPEN_WEB, "Open web console", false);
|
||||||
|
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||||
|
if status.pairing_attention() {
|
||||||
|
add(IDM_PAIRING, "Approve pairing request…", false);
|
||||||
|
}
|
||||||
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||||
|
}
|
||||||
|
if can_control {
|
||||||
|
if startable {
|
||||||
|
add(IDM_START, "Start host", false);
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
add(IDM_STOP, "Stop host", false);
|
||||||
|
add(IDM_RESTART, "Restart host", false);
|
||||||
|
} else if matches!(status, TrayStatus::Error(_)) {
|
||||||
|
add(IDM_RESTART, "Restart host", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(IDM_LOGS, "Open logs folder", false);
|
||||||
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||||
|
add(IDM_EXIT, "Exit tray", false);
|
||||||
|
|
||||||
|
let mut pt = Default::default();
|
||||||
|
let _ = GetCursorPos(&mut pt);
|
||||||
|
let _ = SetForegroundWindow(hwnd);
|
||||||
|
let _ = TrackPopupMenuEx(
|
||||||
|
menu,
|
||||||
|
(TPM_RIGHTBUTTON | TPM_BOTTOMALIGN).0,
|
||||||
|
pt.x,
|
||||||
|
pt.y,
|
||||||
|
hwnd,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let _ = PostMessageW(Some(hwnd), WM_NULL, WPARAM(0), LPARAM(0));
|
||||||
|
let _ = DestroyMenu(menu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ShellExecuteW` "open" on a URL / folder.
|
||||||
|
fn shell_open(hwnd: HWND, target: &str) {
|
||||||
|
let wide = to_wide(target);
|
||||||
|
// SAFETY: all strings nul-terminated and live across the call.
|
||||||
|
unsafe {
|
||||||
|
ShellExecuteW(
|
||||||
|
Some(hwnd),
|
||||||
|
w!("open"),
|
||||||
|
PCWSTR(wide.as_ptr()),
|
||||||
|
PCWSTR::null(),
|
||||||
|
PCWSTR::null(),
|
||||||
|
SW_SHOWNORMAL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One UAC prompt per service action: relaunch the host exe elevated with `service <verb>`.
|
||||||
|
/// A declined prompt (ERROR_CANCELLED) is deliberately ignored.
|
||||||
|
fn elevate_service(hwnd: HWND, verb: &str) {
|
||||||
|
let Some(exe) = app().host_exe.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let exe_w = to_wide(&exe.to_string_lossy());
|
||||||
|
let params = to_wide(&format!("service {verb}"));
|
||||||
|
// SAFETY: nul-terminated strings live across the call; "runas" spawns the elevated child
|
||||||
|
// (hidden console — the tray re-polls for the outcome instead of scraping its output).
|
||||||
|
unsafe {
|
||||||
|
ShellExecuteW(
|
||||||
|
Some(hwnd),
|
||||||
|
w!("runas"),
|
||||||
|
PCWSTR(exe_w.as_ptr()),
|
||||||
|
PCWSTR(params.as_ptr()),
|
||||||
|
PCWSTR::null(),
|
||||||
|
SW_HIDE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(p) = app().poller.get() {
|
||||||
|
p.poke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_web_console(hwnd: HWND) {
|
||||||
|
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_logs(hwnd: HWND) {
|
||||||
|
let Some(base) = std::env::var_os("ProgramData") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dir = std::path::PathBuf::from(base)
|
||||||
|
.join("punktfunk")
|
||||||
|
.join("logs");
|
||||||
|
shell_open(hwnd, &dir.to_string_lossy());
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||||
|
let Some(app) = APP.get() else {
|
||||||
|
// SAFETY: pass-through for messages arriving before APP is set (CreateWindowExW sends
|
||||||
|
// WM_NCCREATE/WM_CREATE synchronously — APP is set before that, but stay defensive).
|
||||||
|
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
|
||||||
|
};
|
||||||
|
match msg {
|
||||||
|
WMAPP_STATUS => {
|
||||||
|
update_icon(hwnd, false);
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
WMAPP_NOTIFYCALLBACK => {
|
||||||
|
// NOTIFYICON_VERSION_4: LOWORD(lParam) is the event.
|
||||||
|
match (lparam.0 as u32) & 0xffff {
|
||||||
|
WM_CONTEXTMENU => show_menu(hwnd),
|
||||||
|
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
|
||||||
|
if app.web_console.load(Ordering::SeqCst) {
|
||||||
|
open_web_console(hwnd);
|
||||||
|
} else {
|
||||||
|
show_menu(hwnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
WM_COMMAND => {
|
||||||
|
match (wparam.0) & 0xffff {
|
||||||
|
IDM_OPEN_WEB => open_web_console(hwnd),
|
||||||
|
IDM_PAIRING => open_web_console(hwnd),
|
||||||
|
IDM_START => elevate_service(hwnd, "start"),
|
||||||
|
IDM_STOP => elevate_service(hwnd, "stop"),
|
||||||
|
IDM_RESTART => elevate_service(hwnd, "restart"),
|
||||||
|
IDM_LOGS => open_logs(hwnd),
|
||||||
|
// SAFETY: DestroyWindow on the wndproc's own window/thread.
|
||||||
|
IDM_EXIT => unsafe {
|
||||||
|
let _ = DestroyWindow(hwnd);
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
WM_CLOSE | WM_ENDSESSION => {
|
||||||
|
// SAFETY: as above — triggers WM_DESTROY below.
|
||||||
|
unsafe {
|
||||||
|
let _ = DestroyWindow(hwnd);
|
||||||
|
}
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
WM_DESTROY => {
|
||||||
|
let nid = NOTIFYICONDATAW {
|
||||||
|
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
|
||||||
|
hWnd: hwnd,
|
||||||
|
uID: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: minimal, correctly sized nid; NIM_DELETE only reads hWnd/uID.
|
||||||
|
unsafe {
|
||||||
|
let _ = Shell_NotifyIconW(NIM_DELETE, &nid);
|
||||||
|
PostQuitMessage(0);
|
||||||
|
}
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
m if m == app.taskbar_created => {
|
||||||
|
// Explorer restarted — the icon is gone; add it back.
|
||||||
|
update_icon(hwnd, true);
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
// SAFETY: default handling for everything else.
|
||||||
|
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Handoff — sealing the gamepad SHM channels
|
||||||
|
|
||||||
|
Status: **implemented (Option A), 2026-07-03 — Windows CI + on-glass validation pending.** The design
|
||||||
|
below was implemented as proposed; the "Implementation notes" section records what was actually built
|
||||||
|
and the deltas. Remaining: build + sign + redeploy both pad drivers, then the hardware validation plan
|
||||||
|
(§Validation) — it needs a physical controller on the box.
|
||||||
|
|
||||||
|
This closes the one open residual left by the IDD-push sealed-channel work
|
||||||
|
(`design/idd-push-security.md`): frames were sealed; the gamepad input/output channel was not.
|
||||||
|
|
||||||
|
## Unsafe hygiene (2026-07-03 follow-up — the drivers' `unsafe` was confined)
|
||||||
|
|
||||||
|
After the seal landed, the pad drivers' `unsafe` footprint (raw `OpenFileMapping`/`MapViewOfFile`,
|
||||||
|
`read_unaligned`, the whole bootstrap state machine as bare-pointer arithmetic) was pulled into a new
|
||||||
|
audited crate **`pf-umdf-util`** (`packaging/windows/drivers/pf-umdf-util/`), so the drivers benefit
|
||||||
|
from Rust instead of being C-in-Rust:
|
||||||
|
|
||||||
|
- `section::MappedView` — a mapped section wrapped as bounds- + alignment-checked accessors
|
||||||
|
(`load_u32`/`store_u32`/`read_bytes`/…). Callers never see the base pointer; an out-of-range offset
|
||||||
|
asserts instead of corrupting. `ViewCell` holds the adopted view as a leaked `&'static` (the
|
||||||
|
re-delivery-must-not-unmap rule, now type-enforced).
|
||||||
|
- `channel::ChannelClient` — the ENTIRE sealed-channel driver side (publish pid → adopt handle →
|
||||||
|
validate magic+`pad_index`), as a **`#![forbid(unsafe_code)]`** module over `MappedView`. One
|
||||||
|
implementation both pad drivers share (was hand-duplicated).
|
||||||
|
- `wdf::{Request, query_location_index, retrieve_next_request}` — the WDF request/memory/property FFI
|
||||||
|
behind safe methods; a callback turns its raw `WDFREQUEST` into a `Request` token once (the only
|
||||||
|
`unsafe` at the driver boundary), and completion consumes the token.
|
||||||
|
|
||||||
|
Result: `pf-xusb`/`pf-dualsense` business logic is **100 % safe Rust**; the only remaining `unsafe` in
|
||||||
|
them is the unavoidable WDF *setup* FFI in `DriverEntry`/`EvtDeviceAdd`/the timer, each with a
|
||||||
|
`// SAFETY:` proof. The display driver `pf-vdisplay` is inherently FFI-bound (D3D11 / IddCx DDIs /
|
||||||
|
cross-process textures) so it can't be unsafe-*free*, but it's now unsafe-*audited*: every `unsafe {}`
|
||||||
|
carries a proof. Both invariants are lint-gated across the whole drivers workspace
|
||||||
|
(`#![deny(unsafe_op_in_unsafe_fn)]` + `#![deny(clippy::undocumented_unsafe_blocks)]`) and enforced by
|
||||||
|
a new `cargo clippy -D warnings` step in `windows-drivers.yml`. Verified on the RTX box (.173): the
|
||||||
|
whole workspace builds + clippies + fmt-checks clean; both gamepad DLLs still produce.
|
||||||
|
|
||||||
|
## Implementation notes (what was built, 2026-07-03)
|
||||||
|
|
||||||
|
- **Contract** (`pf_driver_proto::gamepad`, `GAMEPAD_PROTO_VERSION = 2`): `PadBootstrap` (32 B —
|
||||||
|
`magic "PFBT"`, `host_proto`, `driver_pid`, `driver_proto`, `data_handle: u64`, `handle_pid`,
|
||||||
|
`handle_seq`) with `Pod` + `offset_of!` asserts; `xusb_boot_name`/`pad_boot_name`
|
||||||
|
(`Global\pf…-boot-<index>`) REPLACE the old `*_shm_name` fns (the DATA-section name is gone);
|
||||||
|
`XusbShm`/`PadShm` gained `pad_index` (carved from reserved space) so the DRIVER validates a
|
||||||
|
delivery resolves to *its own* pad — the authentic-side answer to the "redirect the dup into a
|
||||||
|
different pad's WUDFHost" hardening note (the section content is host-written and unreachable by a
|
||||||
|
sibling LS, so the check can't be spoofed). Both pad drivers now path-dep `pf-driver-proto` (as
|
||||||
|
pf-vdisplay does) instead of hand-synced literals.
|
||||||
|
- **Host** (`inject/windows/gamepad_raii.rs`): `Shm::create_unnamed` (DATA, `D:P(A;;GA;;;SY)`) +
|
||||||
|
`Shm::create_named` (mailbox, SY+LS, **squat-checked** — `ERROR_ALREADY_EXISTS` on create is
|
||||||
|
close+retry×5 then a hard error, so the handshake never runs through a pre-created object; this also
|
||||||
|
turns the previously-silent two-hosts-same-index cross-wire into a loud failure). `PadChannel` owns
|
||||||
|
both + the delivery state machine: poll `driver_pid` → `OpenProcess` →
|
||||||
|
`verify_is_wudfhost` (now shared with the frame broker in `capture/windows/idd_push.rs`) →
|
||||||
|
`DuplicateHandle` → publish `data_handle`/`handle_pid`, bump `handle_seq` last (Release). Pumped
|
||||||
|
from each backend's existing service tick (≤4 ms) + a bounded **eager delivery** (1.5 s) at pad-open
|
||||||
|
so the DS4's `device_type` is readable before hidclass asks for descriptors. Delivery attempts are
|
||||||
|
**capped at 16 per pad** so a tampered flapping mailbox can't mint unbounded remote handles. Same
|
||||||
|
pid never retried (failed verify can't be spun into a hot loop).
|
||||||
|
- **Drivers** (`pf-xusb`, `pf-dualsense`): per-tick `pump_bootstrap()` (the DS timer / every XUSB
|
||||||
|
IOCTL + a bounded EvtDeviceAdd worker thread for XUSB's no-game-running case) opens the mailbox *by
|
||||||
|
name each time* — the name existing doubles as host-liveness, replacing the old per-access section
|
||||||
|
open; mailbox gone → detach (DS additionally resets the pended-read report to neutral instead of
|
||||||
|
the old frozen-last-state behavior). The driver writes `driver_proto` always but publishes its pid
|
||||||
|
**only when `host_proto` matches** (fail closed both ways: v1 host never creates a mailbox a v2
|
||||||
|
driver polls; a v1 driver opens a name that no longer exists). A delivery is adopted once
|
||||||
|
(CAS on `handle_seq`, reset when the mailbox disappears so a new host session's counter can't
|
||||||
|
collide), mapped, and validated: `magic` AND `pad_index == SHM_INDEX` — else unmapped + ignored
|
||||||
|
(the handle is deliberately NOT closed on validation failure: a tampered value could name an
|
||||||
|
unrelated handle in the driver's own table). The adopted view is cached and never unmapped
|
||||||
|
(re-delivery swaps + leaks the old 64/256 B mapping on purpose — a concurrent reader may hold it).
|
||||||
|
Driver log line for validation step 3: `sealed pad channel mapped (index …)`.
|
||||||
|
- **Not built:** Option B (devnode custom properties). The residual named mailbox is documented and
|
||||||
|
DoS-bounded; migrate later if it's ever deemed worth removing.
|
||||||
|
|
||||||
|
## The problem (why this exists)
|
||||||
|
|
||||||
|
Each virtual pad's host↔driver channel is a **named** shared-memory section:
|
||||||
|
|
||||||
|
- `Global\pfxusb-shm-<index>` (64 B, [`pf_driver_proto::gamepad::XusbShm`]) — virtual Xbox 360 / XInput.
|
||||||
|
- `Global\pfds-shm-<index>` (256 B, [`pf_driver_proto::gamepad::PadShm`]) — virtual DualSense / DualShock 4.
|
||||||
|
|
||||||
|
Both are created by the SYSTEM host with DACL `D:(A;;GA;;;SY)(A;;GA;;;LS)` (`inject/windows/gamepad_raii.rs`
|
||||||
|
`Shm::create`) so the driver's WUDFHost (LocalService) can open them by name. That means **a sibling
|
||||||
|
LocalService process can `OpenFileMapping` the section by name** and:
|
||||||
|
|
||||||
|
- **read** the victim's live controller input (buttons/sticks/gyro/touchpad — host→driver `input` region), and
|
||||||
|
- **inject/forge** gamepad input or rumble (write the `input` region → the driver feeds it to whatever game
|
||||||
|
has focus; write the `output` region + bump `out_seq` → forge rumble/LED back to the client).
|
||||||
|
|
||||||
|
This is the *same* name-open vector we closed for frames, one module over. Severity is lower than desktop
|
||||||
|
capture (it's game-controller I/O, scoped to the focused app, and requires the attacker to already have
|
||||||
|
LocalService code execution), but it is real and it is inconsistent to leave named next to a sealed frame ring.
|
||||||
|
|
||||||
|
**Not a stopgap:** randomizing the section name is inadequate — the object namespace is enumerable with
|
||||||
|
`NtQueryDirectoryObject`, so a random name is discoverable. (Same reason it was rejected for frames.) The fix
|
||||||
|
is to remove the name.
|
||||||
|
|
||||||
|
## Why it isn't already sealed the frame way
|
||||||
|
|
||||||
|
The frame channel seals cleanly because pf-vdisplay has a **control device** (the IddCx device interface):
|
||||||
|
the host duplicates the unnamed handles into the driver's WUDFHost and delivers the values over
|
||||||
|
`IOCTL_SET_FRAME_CHANNEL`, and the driver reports its own pid in the `IOCTL_ADD` reply.
|
||||||
|
|
||||||
|
The pad drivers (`pf-dualsense`, `pf-xusb`) are **UMDF HID minidrivers with no control device** — hidclass
|
||||||
|
owns the device stack and blocks a freely-openable control interface. That is *why* they use a named section
|
||||||
|
in the first place. So there is no IOCTL to (a) hand the driver a duplicated handle or (b) learn the driver's
|
||||||
|
WUDFHost pid. Compounding it: `pszDeviceLocation` (the existing host→driver property) is fixed at
|
||||||
|
`SwDeviceCreate` time — **before** the WUDFHost process exists — so the host can't duplicate a handle into a
|
||||||
|
not-yet-created process and stamp its value there. A bidirectional, late-bound handshake is required.
|
||||||
|
|
||||||
|
## Current architecture (what to modify)
|
||||||
|
|
||||||
|
Host (`crates/punktfunk-host/src/inject/windows/`):
|
||||||
|
- `gamepad_raii.rs` — `Shm::create(name, size)` creates the **named** section (SY+LS SDDL) + maps it;
|
||||||
|
`SwDevice` wraps the `SwDeviceCreate` devnode.
|
||||||
|
- `gamepad_windows.rs` (XUSB), `dualsense_windows.rs` (DualSense/DS4), `dualshock4_windows.rs` — each creates
|
||||||
|
its `Shm`, then `create_swdevice(index)` / `create_swdevice(profile)` which stamps the pad **index** into
|
||||||
|
`info.pszDeviceLocation` (a UTF-16 decimal string) and creates `pf_xusb_<index>` / `pf_pad_<index>`.
|
||||||
|
|
||||||
|
Driver (`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`):
|
||||||
|
- `query_shm_index(device)` — `WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` → parses the
|
||||||
|
decimal → `SHM_INDEX` static.
|
||||||
|
- On first control activity it builds `format!("Global\\pf…-shm-{}", SHM_INDEX)`, `OpenFileMappingW` +
|
||||||
|
`MapViewOfFile`. The dualsense driver also runs a ~125 Hz timer (writes `driver_heartbeat`) — an existing
|
||||||
|
poll loop to piggyback a bootstrap-wait on.
|
||||||
|
|
||||||
|
Contract (`crates/pf-driver-proto/src/lib.rs` `mod gamepad`): owns `XusbShm`/`PadShm` layouts, the magics,
|
||||||
|
`xusb_shm_name`/`pad_shm_name`, `device_type`, `GAMEPAD_PROTO_VERSION`, and the driver_proto/heartbeat fields.
|
||||||
|
|
||||||
|
## Proposed design — a late-bound bootstrap handshake
|
||||||
|
|
||||||
|
Split each pad's channel into **(1) an unnamed DATA section** (the real `XusbShm`/`PadShm`, host↔driver) and
|
||||||
|
**(2) a tiny bootstrap mailbox** that carries only a magic + the driver's pid + a handle value. The handshake:
|
||||||
|
|
||||||
|
1. **Host**, per pad: create the DATA section **unnamed** (`CreateFileMappingW` with `PCWSTR::null()`, DACL
|
||||||
|
`D:P(A;;GA;;;SY)` — SYSTEM-only, exactly as the sealed frame ring now uses; the driver reaches it by
|
||||||
|
duplicated handle, which carries access, so no LS ACE is needed). Then create the devnode via
|
||||||
|
`SwDeviceCreate`, stamping the pad index into `pszDeviceLocation` **as today** (the index still identifies
|
||||||
|
*which* pad's bootstrap the driver should use).
|
||||||
|
2. **Driver** `EvtDeviceAdd`: read the index (unchanged `query_shm_index`). Write `std::process::id()` where
|
||||||
|
the host can read it, then **poll** (piggyback the existing timer) for a delivered handle value; map the
|
||||||
|
DATA section from it once non-zero.
|
||||||
|
3. **Host**: learn the driver's pid, `OpenProcess(PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION)`,
|
||||||
|
**verify it is the WUDFHost servicing this pad's devnode** (see hardening note), `DuplicateHandle` the
|
||||||
|
DATA section into the WUDFHost, and deliver the resulting handle value back to the driver.
|
||||||
|
|
||||||
|
Two viable transports for steps 2–3's pid-out / handle-in (pick one):
|
||||||
|
|
||||||
|
- **Option A — named bootstrap mailbox** (`Global\pf…-boot-<index>`, ~32 B, SY+LS): host creates it; driver
|
||||||
|
opens it by name (index from location), writes `driver_pid`, spins on `data_handle` != 0; host polls
|
||||||
|
`driver_pid`, dups the DATA section in, writes `data_handle` + a ready seq. **Safe to leave named + SY+LS**
|
||||||
|
because it carries *only* a pid (not sensitive) and a handle value (meaningless outside the target WUDFHost)
|
||||||
|
— identical to the frame channel's "the bootstrap ACL is not load-bearing" argument. A sibling LS that reads
|
||||||
|
it learns nothing exploitable; one that tampers it can at worst feed a bogus pid/handle → the driver maps a
|
||||||
|
value that doesn't resolve in its own table → **DoS, not a breach** (the attacker cannot place a valid
|
||||||
|
section handle in the WUDFHost, so it cannot make the driver map an attacker-controlled section). *Fastest to
|
||||||
|
build — reuses the existing named-section + poll machinery.*
|
||||||
|
- **Option B — devnode custom properties** (no `Global\` object at all): driver writes its pid via
|
||||||
|
`WdfDeviceAssignProperty(DEVPROPKEY_pf_pad_pid)`; host reads it via `CM_Get_DevNode_PropertyW` /
|
||||||
|
`SetupDiGetDevicePropertyW`, dups in, writes a `DEVPROPKEY_pf_pad_handle` property; driver re-queries it in
|
||||||
|
its timer. Tighter (property store isn't world-readable like the Global namespace) but more moving parts and
|
||||||
|
UMDF-property-write ergonomics to prove out. *Cleaner end-state.*
|
||||||
|
|
||||||
|
Recommendation: **build Option A first** (small, mirrors the frame channel, gets the DATA section unnamed —
|
||||||
|
which is the actual isolation win, proven by #3 below), then optionally migrate the bootstrap to Option B if
|
||||||
|
the residual named mailbox is deemed worth removing.
|
||||||
|
|
||||||
|
## Reuse the frame-channel precedent
|
||||||
|
|
||||||
|
- **Ownership/adopt-on-success** discipline from `capture/windows/idd_push.rs` `ChannelBroker` — exactly one
|
||||||
|
side ever closes a duplicated handle value; reap remote duplicates (`DUPLICATE_CLOSE_SOURCE`) on any failure.
|
||||||
|
- **`verify_is_wudfhost`** (`idd_push.rs`) — before duplicating into the driver-reported pid, confirm it's
|
||||||
|
`%SystemRoot%\System32\WUDFHost.exe`. **Strengthen it here**: also confirm the pid is the host *servicing
|
||||||
|
this pad's devnode* (walk devnode → process, e.g. via the driver writing a per-pad nonce it echoes, or a
|
||||||
|
devnode/PID association) so a tampered bootstrap can't redirect the dup into a *different* pad's WUDFHost.
|
||||||
|
- **Contract in `pf_driver_proto::gamepad`** — add the bootstrap layout (`PadBootstrap { magic, driver_pid,
|
||||||
|
data_handle: u64, seq }`) with `Pod` + `offset_of!` asserts, bump `GAMEPAD_PROTO_VERSION`, and (Option A)
|
||||||
|
keep `pad_shm_name`/`xusb_shm_name` only for the bootstrap mailbox, dropping the data-section name.
|
||||||
|
- **SDDL** on the DATA section: `D:P(A;;GA;;;SY)` (SYSTEM-only) — validated safe for a duplicated-handle
|
||||||
|
consumer on the frame ring (the driver's `OpenSharedResource`/`MapViewOfFile` on a handle does not re-check
|
||||||
|
the object DACL).
|
||||||
|
|
||||||
|
## Security properties after the change
|
||||||
|
|
||||||
|
- The **DATA section is unnamed** and only ever handle-duplicated into the pad WUDFHost. Empirically
|
||||||
|
(`design/idd-push-security.md`, RTX box 2026-07-03) a **LocalService token is DACL-denied `OpenProcess` on a
|
||||||
|
UMDF WUDFHost for every access right incl. `QUERY_LIMITED`** — so a sibling LS cannot dup the handle out or
|
||||||
|
read the WUDFHost's memory. Unnamed + unopenable-host ⇒ no sibling-LS path to the input/output data. This is
|
||||||
|
the same guarantee the frame channel now has, and it rests on the same verified property.
|
||||||
|
- **Residual (Option A):** the bootstrap mailbox stays named + SY+LS, but carries only a pid + handle value →
|
||||||
|
worst case a sibling LS causes a **gamepad DoS**, never a read or injection. Option B removes even that.
|
||||||
|
- **Unchanged inherent limits:** admin/SYSTEM = total; the game reading the pad sees the input by design.
|
||||||
|
|
||||||
|
## Validation plan (needs hardware)
|
||||||
|
|
||||||
|
The blocker for calling this done is that it **requires a physical controller on the box** — the memory notes
|
||||||
|
repeatedly flag the gamepad path as "needs a physical pad to live-verify," and neither the probe nor a
|
||||||
|
synthetic client exercises a real game reading the virtual pad.
|
||||||
|
|
||||||
|
1. Build + sign + redeploy `pf-dualsense` and `pf-xusb` (same loop as pf-vdisplay:
|
||||||
|
`packaging/windows/drivers/deploy-dev.ps1` per driver, or `redeploy-*`; DriverVer must strictly increase).
|
||||||
|
Bump `GAMEPAD_PROTO_VERSION` — a v_new host against a v_old pad driver (or vice-versa) must fail closed, so
|
||||||
|
deploy host + both pad drivers together.
|
||||||
|
2. Connect a real client with a physical controller; confirm in a game that input works and rumble/LED return.
|
||||||
|
3. Driver log (`C:\Users\Public\pfds-driver.log` / `pfxusb-driver.log` in debug builds): confirm the driver
|
||||||
|
reports its pid, receives a handle, and maps the DATA section (add a `dbglog!` "sealed pad channel mapped").
|
||||||
|
4. Re-run the **sibling-LS `OpenFileMapping` test**: from a LocalService scheduled task, attempt to open the
|
||||||
|
old `Global\pf…-shm-<index>` name — it must now **fail (name gone)**, and attempting to open the bootstrap
|
||||||
|
(Option A) must yield only pid+handle bytes. (Reuse the scheduled-task P/Invoke harness from the #3 frame
|
||||||
|
test — see the session that produced `design/idd-push-security.md`.)
|
||||||
|
5. Multi-pad: two controllers → two devnodes, two unnamed DATA sections, two bootstraps by index; confirm no
|
||||||
|
cross-talk and clean teardown (`SwDeviceClose` + host handle close; the WUDFHost dies with its devnode).
|
||||||
|
|
||||||
|
## Risks / gotchas
|
||||||
|
|
||||||
|
- **Regression risk to a working feature.** Gamepad input currently works on glass; this reroutes its
|
||||||
|
bootstrap. Keep the change behind the `GAMEPAD_PROTO_VERSION` bump and be ready to revert both drivers.
|
||||||
|
- **Chicken-and-egg timing.** The driver loads and wants the handle before the host has dup'd it — the poll
|
||||||
|
loop must tolerate a bounded wait (mirror the frame path's `wait_for_attach`, ~4 s) and the driver must not
|
||||||
|
block `EvtDeviceAdd` on it (spin in the timer, not the add callback).
|
||||||
|
- **Handle value in shared memory is a `u64`.** A WUDFHost handle value is process-local; writing it to the
|
||||||
|
bootstrap is safe (meaningless elsewhere), but the driver must treat it as untrusted (validate the mapped
|
||||||
|
DATA section's magic before use — the existing `XusbShm`/`PadShm` magic already gives this).
|
||||||
|
- **Two drivers, one contract.** DualSense and DualShock 4 share `pf-dualsense`/`PadShm`; XUSB is separate.
|
||||||
|
Factor the bootstrap into `pf_driver_proto::gamepad` so both drivers + the host use one definition (as the
|
||||||
|
frame channel does).
|
||||||
|
|
||||||
|
## Effort
|
||||||
|
|
||||||
|
Medium — comparable to the frame sealed-channel change but across **two** drivers plus the host inject code,
|
||||||
|
and gated on **physical-controller validation** that can't be driven over SSH. Files: `pf_driver_proto`
|
||||||
|
(gamepad module), `inject/windows/{gamepad_raii,gamepad_windows,dualsense_windows,dualshock4_windows}.rs`,
|
||||||
|
`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`. Reference implementation: the frame sealed channel
|
||||||
|
(`capture/windows/idd_push.rs` + `packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs`
|
||||||
|
+ `pf_driver_proto` `control`/`frame`).
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# IDD-push frame channel — security model (the sealed channel)
|
||||||
|
|
||||||
|
Status: **implemented** (host `capture/windows/idd_push.rs` + driver
|
||||||
|
`packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs`, contract
|
||||||
|
`crates/pf-driver-proto` v2). Windows CI-validated; on-glass validation pending.
|
||||||
|
|
||||||
|
## What is being protected
|
||||||
|
|
||||||
|
The IDD-push path moves **whole-desktop frames** — including the secure desktop (UAC prompts, the
|
||||||
|
lock screen) — from the pf-vdisplay driver (UMDF, running in a `WUDFHost.exe` under LocalService)
|
||||||
|
into the SYSTEM host for encoding. That data is SYSTEM-tier-sensitive, and because we bypass the OS
|
||||||
|
capture APIs (Desktop Duplication / WGC), **we own the isolation those APIs would have provided.**
|
||||||
|
|
||||||
|
DDA's isolation property is that capturer and consumer are the same process: there is no openable
|
||||||
|
channel at all — to reach the frames you must own the capturing process. The sealed channel
|
||||||
|
reproduces exactly that property for our two-process design.
|
||||||
|
|
||||||
|
## The design
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐ control device (SY+BA only) ┌───────────────────────────┐
|
||||||
|
│ Host (SYSTEM service) │ ── IOCTL_SET_FRAME_CHANNEL: handle ────▶ │ pf-vdisplay driver │
|
||||||
|
│ creates header/event/ │ VALUES only (integers) │ (WUDFHost, LocalService) │
|
||||||
|
│ ring textures UNNAMED, │ │ maps/opens the duplicated │
|
||||||
|
│ DuplicateHandle()s them │ ◀── frames via keyed-mutex textures ──── │ handles; publishes frames │
|
||||||
|
│ INTO WUDFHost, encodes │ (no names anywhere) │ │
|
||||||
|
└──────────────────────────┘ └───────────────────────────┘
|
||||||
|
trust boundary: only these two processes ever hold a handle to any frame object
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Every frame object is unnamed** (header section, frame-ready event, all ring textures —
|
||||||
|
`CreateFileMappingW`/`CreateEventW`/`CreateSharedHandle` with a null name). An unnamed object is
|
||||||
|
in no namespace: it cannot be enumerated (`NtQueryDirectoryObject` can't see it), cannot be
|
||||||
|
opened by name, and cannot be pre-created ("squatted"). It can be shared **only** by handle
|
||||||
|
duplication.
|
||||||
|
2. **The host is the broker.** SYSTEM opens the driver's WUDFHost with `PROCESS_DUP_HANDLE` (the pid
|
||||||
|
comes from the `IOCTL_ADD` reply, per-monitor, so a WUDFHost restart can't leave us duplicating
|
||||||
|
into a dead process) and `DuplicateHandle`s each object in. The reverse direction — LocalService
|
||||||
|
injecting into SYSTEM — is correctly denied by the OS, which is why the broker must be the host.
|
||||||
|
3. **The bootstrap carries only integers.** `IOCTL_SET_FRAME_CHANNEL` delivers the duplicated handle
|
||||||
|
*values*. A handle value is only meaningful inside the target process's handle table: a third
|
||||||
|
party that read (or even forged) the message would learn nothing openable and could at most feed
|
||||||
|
values that don't resolve — a DoS of its own session, not a read. The bootstrap's ACL is therefore
|
||||||
|
**not load-bearing**; we still restrict the control device to `D:P(A;;GA;;;SY)(A;;GA;;;BA)`
|
||||||
|
(INF `Security`), because ADD/REMOVE/CLEAR_ALL shouldn't be world-callable either.
|
||||||
|
|
||||||
|
Net result: the only way to reach the frames is to already run code as SYSTEM (the host) or inside
|
||||||
|
that specific WUDFHost (the driver) — DDA's property, achieved in user mode.
|
||||||
|
|
||||||
|
## Why user-mode, not a kernel driver
|
||||||
|
|
||||||
|
Ring level does not govern cross-process memory visibility — the handle/VAD access checks do; a user
|
||||||
|
process cannot `ReadProcessMemory` a LocalService process regardless of rings. What kernel-mode
|
||||||
|
*would* change is the blast radius of a driver bug: UMDF caps a pf-vdisplay compromise at the
|
||||||
|
LocalService token, a KMDF display driver would make it ring-0 full-system. Least-blast-radius is
|
||||||
|
the reason punktfunk ships **zero** kernel drivers (the gamepad stack dropped ViGEmBus for UMDF for
|
||||||
|
the same reason). The correct control for "SYSTEM-tier data in the channel" is sealing the channel —
|
||||||
|
done above — not raising the ring.
|
||||||
|
|
||||||
|
## Handle-lifetime invariants (the auditable list)
|
||||||
|
|
||||||
|
1. Frame objects unnamed; bootstrap carries only handle values. ✔ by construction
|
||||||
|
2. `bInheritHandle: false` on every object — no child inherits a handle. ✔
|
||||||
|
3. Zero-init header + atomic `magic`-last publish (the driver never acts on a half-initialized
|
||||||
|
ring); generation-tagged publish tokens reject stale-ring frames. ✔
|
||||||
|
4. Attacker-influenced header fields are bounds-checked before use (generation/seq/slot unpacking;
|
||||||
|
`ring_len` clamped; the driver validates `IOCTL_SET_FRAME_CHANNEL` before adopting anything). ✔
|
||||||
|
5. **Adopt-on-success-only:** the driver owns (and eventually closes) the delivered handles iff the
|
||||||
|
IOCTL completed successfully; on ANY error completion it leaves them untouched and the host reaps
|
||||||
|
its remote duplicates (`DUPLICATE_CLOSE_SOURCE`). Exactly one side closes each value — no
|
||||||
|
double-close of possibly-reused handle values, no leak on a half-delivered channel. ✔
|
||||||
|
6. Single ownership inside the driver: each delivery lives in exactly one place (monitor stash →
|
||||||
|
publisher), and whichever owner dies — replaced stash, dropped publisher, removed monitor, reaped
|
||||||
|
watchdog, departed device — closes the handles (`FrameChannel`/publisher `Drop`). Host-side
|
||||||
|
objects are RAII (`MappedSection`, `OwnedHandle`); nothing survives the capturer. ✔
|
||||||
|
7. The object DACL is `D:P(A;;GA;;;SY)` — **SYSTEM only, protected**. Since the driver reaches the
|
||||||
|
objects via duplicated handles (which carry their own access; `OpenSharedResource1` on a handle does
|
||||||
|
not re-check the object DACL), the LocalService ACE was dropped — the minimal DACL. ✔ *(on-glass
|
||||||
|
confirmed 2026-07-03: the driver still attaches + delivers frames with SYSTEM-only objects.)*
|
||||||
|
8. **The duplication target is verified.** Before duplicating frame handles into `AddReply.wudf_pid`,
|
||||||
|
the host confirms that pid is `%SystemRoot%\System32\WUDFHost.exe` (`verify_is_wudfhost`). A spoofed
|
||||||
|
devnode advertising our interface GUID cannot redirect frames to an arbitrary process. ✔
|
||||||
|
9. **Handles are duplicated with least privilege, not `DUPLICATE_SAME_ACCESS`.** The driver's copy of
|
||||||
|
the header section is `SECTION_MAP_READ|WRITE` (matched by the driver mapping `FILE_MAP_READ|WRITE`,
|
||||||
|
not `FILE_MAP_ALL_ACCESS`), the frame-ready event is `EVENT_MODIFY_STATE` (the driver only signals
|
||||||
|
it), and the ring textures keep their already-scoped `CreateSharedHandle` access
|
||||||
|
(`DXGI_SHARED_RESOURCE_READ|WRITE`). So a compromised driver's handles can map/signal but cannot
|
||||||
|
`WRITE_DAC`/`WRITE_OWNER`/`DELETE` the objects — the "give unnamed shared objects proper (minimal)
|
||||||
|
security attributes, because `DuplicateHandle` can still reach them" discipline (Raymond Chen,
|
||||||
|
*devblogs 2015-06-04*). Marginal here (the driver is already a trusted frame endpoint) but correct
|
||||||
|
hygiene, and it applies identically to the gamepad DATA section. ✔ *(on-glass confirmed 2026-07-03:
|
||||||
|
the driver attaches + streams `frames=7035` with the least-access header handle.)*
|
||||||
|
|
||||||
|
Ring recreation (mid-session HDR flip) and host build-retries re-deliver a complete fresh handle set;
|
||||||
|
the driver treats a pending delivery as newest-wins (a retry's ring is a *different* header mapping,
|
||||||
|
whose generation bump an old publisher can never observe).
|
||||||
|
|
||||||
|
## Empirical verification (2026-07-03, RTX box)
|
||||||
|
|
||||||
|
The headline claim — "reaching a frame requires already being one of the two endpoint processes" —
|
||||||
|
was tested, not just argued. A **LocalService-token** process (scheduled task, the sibling-service
|
||||||
|
stand-in) attempting `OpenProcess` on the pf_vdisplay WUDFHost was **denied every access right**:
|
||||||
|
`PROCESS_DUP_HANDLE`, `PROCESS_VM_READ`, `PROCESS_QUERY_INFORMATION`, and even
|
||||||
|
`PROCESS_QUERY_LIMITED_INFORMATION` → `ERROR_ACCESS_DENIED`. The `QUERY_LIMITED` denial is decisive:
|
||||||
|
it is a read-class right MIC permits across integrity levels, so its denial is a **DACL exclusion of
|
||||||
|
the LocalService SID**, not an integrity ceiling — meaning even a higher-integrity LocalService
|
||||||
|
*service* is denied (LocalService lacks `SeDebugPrivilege`, so it cannot bypass the DACL). Combined
|
||||||
|
with the objects being unnamed, a sibling LocalService has **no reachable path to a frame**: no
|
||||||
|
name to open, no way to dup the handles out of WUDFHost, no way to read WUDFHost's memory. The
|
||||||
|
baseline (an elevated admin, holding `SeDebugPrivilege`) opened WUDFHost freely — expected, and the
|
||||||
|
reason "admin/SYSTEM = total" stays on the residual list below.
|
||||||
|
|
||||||
|
## Residual limits — the honest floor
|
||||||
|
|
||||||
|
* **The virtual display is a real monitor.** Any process in the interactive session can capture it
|
||||||
|
through the ordinary OS APIs (DDA/WGC/BitBlt), exactly as it can capture any physical monitor.
|
||||||
|
That floor is identical for every virtual-display streaming stack (Sunshine + VDD, Apollo/SudoVDA);
|
||||||
|
the sealed channel keeps *our* transport above that floor rather than below it. **This is the single
|
||||||
|
most realistic way for unprivileged session code to see the streamed pixels, and it is outside our
|
||||||
|
channel entirely.**
|
||||||
|
* **The gamepad channels are now sealed too** (2026-07-03, `design/gamepad-channel-sealing.md`,
|
||||||
|
gamepad proto v2 — on-glass validation pending): the pad DATA sections (`XusbShm`/`PadShm`) are
|
||||||
|
UNNAMED with `D:P(A;;GA;;;SY)`, handle-duplicated into the pad's WUDFHost by the host broker
|
||||||
|
(`inject/windows/gamepad_raii.rs` `PadChannel`, reusing this design's `verify_is_wudfhost` +
|
||||||
|
adopt-on-success discipline), and the driver validates the mapped section's magic + `pad_index`
|
||||||
|
before use. The pad drivers have no control device (hidclass), so the handshake runs over a tiny
|
||||||
|
**named bootstrap mailbox** (`Global\pf…-boot-<index>`, SY+LS, `PadBootstrap`) that carries only
|
||||||
|
pids and a handle value — nothing exploitable; the *residual* is that a sibling LocalService can
|
||||||
|
tamper the mailbox for a **gamepad DoS** (never a read or an injection; deliveries are capped, and
|
||||||
|
the mailbox is squat-checked at create). The old sibling-LS read/inject vector on
|
||||||
|
`Global\pf…-shm-*` is gone — the names no longer exist.
|
||||||
|
* **Admin / SYSTEM = total.** The control device is `D:P(A;;GA;;;SY)(A;;GA;;;BA)`, so an admin can drive
|
||||||
|
`IOCTL_SET_FRAME_CHANNEL` (DoS a live session) and, with `SeDebugPrivilege`, dup a section into
|
||||||
|
WUDFHost to exfiltrate; and an admin can plant a fake devnode with our interface GUID to impersonate
|
||||||
|
the driver. All admin-gated (no non-privileged escalation), but the control plane is explicitly not a
|
||||||
|
boundary against admin. The host↔driver channel has no mutual authentication beyond the `GET_INFO`
|
||||||
|
version handshake + the `verify_is_wudfhost` image check.
|
||||||
|
* **`WDA_EXCLUDEFROMCAPTURE` windows are visible.** IDD-push taps the *present* side, not the
|
||||||
|
*capture* side, so windows that exclude themselves from capture still appear in the stream — true
|
||||||
|
of every virtual-display streaming stack. Untested on our lab box; treat as expected behavior.
|
||||||
|
* **DRM/HDCP:** protected content is blanked by DWM at composition, and HDCP is a monitor↔GPU
|
||||||
|
handshake an indirect display cannot satisfy — neither is bypassed by this path.
|
||||||
|
* IDD-push is currently the **sole Windows capture path** (DDA and the WGC relay were removed). An
|
||||||
|
OS-mediated-capture-only mode would trade away secure-desktop capture and latency; if a deployment
|
||||||
|
requires it, that's a feature request, not a toggle that exists today.
|
||||||
@@ -45,7 +45,10 @@ interactive session for secure-desktop capture (why MSIX is unusable - see
|
|||||||
| `packaging/windows/drivers/pf-dualsense/` `pf-xusb/` | `build-gamepad-drivers.ps1` (sign the workspace build) | `pf_{dualsense,xusb}.{dll,inf,cat}` + shared `.cer` |
|
| `packaging/windows/drivers/pf-dualsense/` `pf-xusb/` | `build-gamepad-drivers.ps1` (sign the workspace build) | `pf_{dualsense,xusb}.{dll,inf,cat}` + shared `.cer` |
|
||||||
| `packaging/windows/pf-vkhdr-layer/` | `pack-host-installer.ps1` (`cargo build --release`) | `pf_vkhdr_layer.dll` + `.json` |
|
| `packaging/windows/pf-vkhdr-layer/` | `pack-host-installer.ps1` (`cargo build --release`) | `pf_vkhdr_layer.dll` + `.json` |
|
||||||
| `web/` | `scripts/windows/build-web.ps1` (`bun run build`) | self-contained `.output` |
|
| `web/` | `scripts/windows/build-web.ps1` (`bun run build`) | self-contained `.output` |
|
||||||
| `packaging/windows/nvenc/nvenc.def` | `gen-nvenc-importlib.ps1` (llvm-dlltool) | `nvencodeapi.lib` (link import, no GPU/SDK) |
|
|
||||||
|
(NVENC needs no build artifact: its entry points are resolved at runtime from the driver's
|
||||||
|
`nvEncodeAPI64.dll` — a link-time import would prevent the all-vendor exe from starting on
|
||||||
|
AMD/Intel-only machines.)
|
||||||
|
|
||||||
## 3. The driver workspace - `packaging/windows/drivers/`
|
## 3. The driver workspace - `packaging/windows/drivers/`
|
||||||
|
|
||||||
@@ -118,8 +121,9 @@ needs, on the runner:
|
|||||||
to the runner default). *History:* LLVM 21.1.2 was briefly pinned (`C:\llvm-21`) to dodge a
|
to the runner default). *History:* LLVM 21.1.2 was briefly pinned (`C:\llvm-21`) to dodge a
|
||||||
bindgen-0.71 layout-test overflow on clang 22; the 0.72 bump retired that pin, so there's now one
|
bindgen-0.71 layout-test overflow on clang 22; the 0.72 bump retired that pin, so there's now one
|
||||||
toolchain for both driver builds (the pack and `windows-drivers.yml`).
|
toolchain for both driver builds (the pack and `windows-drivers.yml`).
|
||||||
- NVENC import lib synthesised from a 2-export `.def` via `llvm-dlltool` (`gen-nvenc-importlib.ps1`) -
|
- NVENC needs nothing at build time: the entry points are runtime-loaded from the driver's
|
||||||
no GPU or NVIDIA SDK at build time.
|
`nvEncodeAPI64.dll` (`encode/windows/nvenc.rs` `load_api`). A link-time import would stop the
|
||||||
|
all-vendor exe from even starting on AMD/Intel-only machines.
|
||||||
- `FFMPEG_DIR` (the BtbN gpl-shared x64 tree) for the AMD/Intel AMF/QSV link; NASM + CMake +
|
- `FFMPEG_DIR` (the BtbN gpl-shared x64 tree) for the AMD/Intel AMF/QSV link; NASM + CMake +
|
||||||
`CMAKE_POLICY_VERSION_MINIMUM=3.5` for the CMake-from-source deps (aws-lc, opus).
|
`CMAKE_POLICY_VERSION_MINIMUM=3.5` for the CMake-from-source deps (aws-lc, opus).
|
||||||
- **Gotcha:** `CARGO_HOME` must be an ASCII path (a non-ASCII username breaks SDL3's MSVC precompiled
|
- **Gotcha:** `CARGO_HOME` must be an ASCII path (a non-ASCII username breaks SDL3's MSVC precompiled
|
||||||
@@ -143,6 +147,13 @@ tasks** (all default-checked): install the pf-vdisplay driver, install the gamep
|
|||||||
HDR Vulkan layer, start the service. Silent install: `/VERYSILENT` (omit a task with
|
HDR Vulkan layer, start the service. Silent install: `/VERYSILENT` (omit a task with
|
||||||
`/MERGETASKS="!installdriver"`).
|
`/MERGETASKS="!installdriver"`).
|
||||||
|
|
||||||
|
**OS floor: Windows 11 22H2 (build 22621)** — `MinVersion=10.0.22621`, with a `[Messages]
|
||||||
|
WinVersionTooLowError` override naming the requirement. pf-vdisplay is built against **IddCx 1.10**
|
||||||
|
(the 1.10 `IddCxStub`, HDR `*2` DDIs, FP16 caps; no runtime `IddCxGetVersion` downgrade), which first
|
||||||
|
shipped in Windows 11 22H2 — on Windows 10 (incl. LTSC) / Windows 11 21H2 the driver package installs
|
||||||
|
but the device fails start with Code 10 `STATUS_DEVICE_POWER_FAILURE` (field-reported on Windows 10
|
||||||
|
LTSC, 2026-07). The installer gate turns that late failure into an upfront message.
|
||||||
|
|
||||||
Install-time work runs from `punktfunk-host.exe` subcommands, **not** locale-parsed PowerShell *files* -
|
Install-time work runs from `punktfunk-host.exe` subcommands, **not** locale-parsed PowerShell *files* -
|
||||||
the `[Run]` section calls `driver install [--gamepad] --dir <stage>` and `web setup --app-dir <app>
|
the `[Run]` section calls `driver install [--gamepad] --dir <stage>` and `web setup --app-dir <app>
|
||||||
[--password-file <f>]` (`crates/punktfunk-host/src/windows/install.rs`). This is the ANSI-codepage
|
[--password-file <f>]` (`crates/punktfunk-host/src/windows/install.rs`). This is the ANSI-codepage
|
||||||
|
|||||||
@@ -82,12 +82,18 @@ query.
|
|||||||
|
|
||||||
**IDD-push is the universal primary path.** Capture comes straight from the driver's shared keyed-mutex
|
**IDD-push is the universal primary path.** Capture comes straight from the driver's shared keyed-mutex
|
||||||
texture ring (`capture/windows/idd_push.rs`) — no Desktop Duplication, no `win32u` reparenting hook. The
|
texture ring (`capture/windows/idd_push.rs`) — no Desktop Duplication, no `win32u` reparenting hook. The
|
||||||
host creates the ring; the driver opens it (permissive `D:(A;;GA;;;WD)` SDDL). The generation-tagged
|
host creates the ring as a **sealed channel** (proto v2, `design/idd-push-security.md`): the header,
|
||||||
`latest = gen<<40 | seq<<8 | slot` stale-ring reject kills the HDR-flip garbage frame; a host-owned
|
frame-ready event, and ring textures are **unnamed** (nothing to enumerate, open by name, or squat), and
|
||||||
3-slot `OUT_RING` rotated per frame is the texture-ownership contract that enables `pipeline_depth=2`
|
the host `DuplicateHandle`s them into the driver's WUDFHost and delivers the handle *values* over the
|
||||||
(convert/copy on the 3D engine overlapping NVENC on the ASIC). It captures the **secure desktop**
|
SYSTEM+admins-only control device (`IOCTL_SET_FRAME_CHANNEL`), so only the two endpoint processes can
|
||||||
(Winlogon/UAC/lock) directly (validated 2026-06-25), so there is no separate secure capturer in the
|
ever reach a frame — DDA's isolation property in user mode. (The objects keep a `D:(A;;GA;;;SY)(A;;GA;;;LS)`
|
||||||
primary path.
|
DACL as defense-in-depth; it is no longer the isolation boundary. This supersedes the earlier named-ring
|
||||||
|
scheme, which was world-openable `Global\pfvd-*` (`D:(A;;GA;;;WD)`) then SY+LS-scoped.) The
|
||||||
|
generation-tagged `latest = gen<<40 | seq<<8 | slot` stale-ring reject kills the HDR-flip garbage frame;
|
||||||
|
a host-owned 3-slot `OUT_RING` rotated per frame is the texture-ownership contract that enables
|
||||||
|
`pipeline_depth=2` (convert/copy on the 3D engine overlapping NVENC on the ASIC). It captures the
|
||||||
|
**secure desktop** (Winlogon/UAC/lock) directly (validated 2026-06-25), so there is no separate secure
|
||||||
|
capturer in the primary path.
|
||||||
|
|
||||||
- **Open-time fallback:** `IddPushCapturer::open` waits a bounded ~4 s for a *first frame* (not just
|
- **Open-time fallback:** `IddPushCapturer::open` waits a bounded ~4 s for a *first frame* (not just
|
||||||
`DRV_STATUS_OPENED`); on attach failure it returns the keepalive back so `capture.rs` opens **DDA** on
|
`DRV_STATUS_OPENED`); on attach failure it returns the keepalive back so `capture.rs` opens **DDA** on
|
||||||
@@ -120,10 +126,12 @@ loss-recovery by query (only Windows direct-NVENC overrides it; the GameStream l
|
|||||||
### 2.5 Host↔driver ABI & the `pf-vdisplay` driver
|
### 2.5 Host↔driver ABI & the `pf-vdisplay` driver
|
||||||
|
|
||||||
`pf-driver-proto` is one `no_std` crate in both build graphs. It owns the **frame plane** (`FrameToken`
|
`pf-driver-proto` is one `no_std` crate in both build graphs. It owns the **frame plane** (`FrameToken`
|
||||||
+ `Global\pfvd-*` names), the **control plane** (a fresh interface GUID — *not* SudoVDA's `e5bcc234`;
|
+ `SharedHeader`; since proto v2 the frame objects are **unnamed** — no `Global\pfvd-*` names — and are
|
||||||
contiguous `0x900` IOCTL ops; a `GET_INFO` version handshake the host **asserts** + bails on mismatch),
|
delivered by handle duplication over `IOCTL_SET_FRAME_CHANNEL`, the *sealed channel*:
|
||||||
and the **gamepad SHM** (`XusbShm`/`PadShm` incl. `device_type`). `bytemuck`-`Pod` + `size_of` **and**
|
`design/idd-push-security.md`), the **control plane** (a fresh interface GUID — *not* SudoVDA's
|
||||||
`offset_of!` asserts make ABI drift a **compile error**.
|
`e5bcc234`; contiguous `0x900` IOCTL ops; a `GET_INFO` version handshake the host **asserts** + bails on
|
||||||
|
mismatch), and the **gamepad SHM** (`XusbShm`/`PadShm` incl. `device_type`). `bytemuck`-`Pod` +
|
||||||
|
`size_of` **and** `offset_of!` asserts make ABI drift a **compile error**.
|
||||||
|
|
||||||
The driver (`packaging/windows/drivers/pf-vdisplay/src/`) is an all-Rust UMDF IddCx driver on
|
The driver (`packaging/windows/drivers/pf-vdisplay/src/`) is an all-Rust UMDF IddCx driver on
|
||||||
`windows-drivers-rs` + the `iddcx` `wdk-sys` subset; the STEP 0–8 build is the checklist in §6.3, its
|
`windows-drivers-rs` + the `iddcx` `wdk-sys` subset; the STEP 0–8 build is the checklist in §6.3, its
|
||||||
@@ -200,8 +208,10 @@ These are expensive empirical wins; keep them intact when touching the code:
|
|||||||
the hot-loop `KeyedMutexGuard`, and the driver's `pod_init!`; all box-validated, clean `sc stop` in
|
the hot-loop `KeyedMutexGuard`, and the driver's `pod_init!`; all box-validated, clean `sc stop` in
|
||||||
~1 s). The driver already has the deny. Revisit D1-host as a final discipline pass (staged per-module)
|
~1 s). The driver already has the deny. Revisit D1-host as a final discipline pass (staged per-module)
|
||||||
if desired.
|
if desired.
|
||||||
5. **M6 scaffolding cleanup** — delete the bring-up diagnostics (`spawn_observer`/`DebugBlock` in
|
5. **M6 scaffolding cleanup** — the bring-up diagnostics (`spawn_observer`/`DebugBlock` in
|
||||||
`idd_push.rs`) and, once full parity is proven on glass, the host monoliths.
|
`idd_push.rs`) were deleted with the sealed-channel change (they were the last fixed-name
|
||||||
|
`Global\` objects on the frame path); once full parity is proven on glass, the host monoliths
|
||||||
|
remain.
|
||||||
|
|
||||||
**Explicitly NOT doing (stability decision): E1 — driver `DeviceContext` ownership + per-`IDDCX_MONITOR`
|
**Explicitly NOT doing (stability decision): E1 — driver `DeviceContext` ownership + per-`IDDCX_MONITOR`
|
||||||
`EvtCleanupCallback`.** The current process-global design is *sound*: IddCx DDIs receive only an
|
`EvtCleanupCallback`.** The current process-global design is *sound*: IddCx DDIs receive only an
|
||||||
@@ -260,7 +270,7 @@ Local pre-push checks (this Linux box can't compile the Windows paths):
|
|||||||
cargo test -p pf-driver-proto # the ABI crate (cross-platform)
|
cargo test -p pf-driver-proto # the ABI crate (cross-platform)
|
||||||
cargo check -p punktfunk-host # Linux paths; win_* mods are #[cfg(windows)]
|
cargo check -p punktfunk-host # Linux paths; win_* mods are #[cfg(windows)]
|
||||||
cargo clippy -p punktfunk-host --all-targets -- -D warnings
|
cargo clippy -p punktfunk-host --all-targets -- -D warnings
|
||||||
# Windows host clippy (on the box): PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc;
|
# Windows host clippy (on the box; NVENC needs no import lib — runtime-loaded):
|
||||||
# cargo clippy -p punktfunk-host --features nvenc --target x86_64-pc-windows-msvc -- -D warnings
|
# cargo clippy -p punktfunk-host --features nvenc --target x86_64-pc-windows-msvc -- -D warnings
|
||||||
# Driver build (on the box): cd packaging/windows/drivers; Version_Number=10.0.26100.0;
|
# Driver build (on the box): cd packaging/windows/drivers; Version_Number=10.0.26100.0;
|
||||||
# LIBCLANG_PATH='C:\Program Files\LLVM\bin'; cargo build
|
# LIBCLANG_PATH='C:\Program Files\LLVM\bin'; cargo build
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam
|
|||||||
> pure desktop machine, [Ubuntu/Fedora KDE](/docs/ubuntu-kde) or [GNOME](/docs/ubuntu-gnome) are
|
> pure desktop machine, [Ubuntu/Fedora KDE](/docs/ubuntu-kde) or [GNOME](/docs/ubuntu-gnome) are
|
||||||
> simpler.
|
> simpler.
|
||||||
|
|
||||||
|
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||||
|
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora
|
The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ systemd service and uses KWin to create per-client virtual displays, captured ze
|
|||||||
> Validated live on **Fedora 44 KDE Plasma** with an RTX 4090: KWin virtual output + full
|
> Validated live on **Fedora 44 KDE Plasma** with an RTX 4090: KWin virtual output + full
|
||||||
> zero-copy capture. Everything below is the reproducible flow — paste it on a fresh box.
|
> zero-copy capture. Everything below is the reproducible flow — paste it on a fresh box.
|
||||||
|
|
||||||
|
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||||
|
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||||
|
|
||||||
The setup has three parts: **NVIDIA driver** → **host RPM** → **KWin streaming session**.
|
The setup has three parts: **NVIDIA driver** → **host RPM** → **KWin streaming session**.
|
||||||
|
|
||||||
## 1. NVIDIA driver (RPM Fusion akmod)
|
## 1. NVIDIA driver (RPM Fusion akmod)
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ description: Install the punktfunk host — on Linux from its package registry,
|
|||||||
On Linux, the package registries are the real distribution channel. Pick your distro, add the repo, and
|
On Linux, the package registries are the real distribution channel. Pick your distro, add the repo, and
|
||||||
install with your native package manager. Each row links to the full per-distro guide (add the repo,
|
install with your native package manager. Each row links to the full per-distro guide (add the repo,
|
||||||
first-run steps, the web console) — those are the source of truth, so this page doesn't duplicate them.
|
first-run steps, the web console) — those are the source of truth, so this page doesn't duplicate them.
|
||||||
On **Windows** (NVIDIA), the host ships as a signed installer instead — see [Windows](#windows-nvidia).
|
On **Windows**, the host ships as a signed installer instead — see [Windows](#windows).
|
||||||
|
|
||||||
|
> **First, read [Security & Safe Use](/docs/security).** A streaming host is remote control of the
|
||||||
|
> machine. It's built for trusted local networks — don't expose it to the internet, and be thoughtful
|
||||||
|
> about which machine you host on (especially on Windows).
|
||||||
|
|
||||||
## Pick your distro
|
## Pick your distro
|
||||||
|
|
||||||
@@ -26,10 +30,10 @@ tracks new builds automatically.
|
|||||||
> at the **canary** channel instead (`canary` apt distribution / `*-canary` rpm group). See
|
> at the **canary** channel instead (`canary` apt distribution / `*-canary` rpm group). See
|
||||||
> [Release Channels](/docs/channels).
|
> [Release Channels](/docs/channels).
|
||||||
|
|
||||||
## Windows (NVIDIA)
|
## Windows
|
||||||
|
|
||||||
punktfunk also runs as a native host on **Windows 10/11 (x64) with an NVIDIA GPU**, shipped as a
|
punktfunk also runs as a native host on **Windows 11 22H2+ (x64)**, shipped as a signed
|
||||||
signed installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations.
|
installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations.
|
||||||
|
|
||||||
1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest
|
1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest
|
||||||
**`punktfunk-host-setup-<ver>.exe`** and its matching **`.cer`**.
|
**`punktfunk-host-setup-<ver>.exe`** and its matching **`.cer`**.
|
||||||
@@ -53,7 +57,7 @@ fallback without one. More detail — including the CLI `punktfunk-host service
|
|||||||
|
|
||||||
## What the packages are
|
## What the packages are
|
||||||
|
|
||||||
- **`punktfunk-host`** — the streaming host. Install this on your Linux + NVIDIA gaming machine.
|
- **`punktfunk-host`** — the streaming host. Install this on your Linux gaming machine.
|
||||||
- **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the
|
- **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the
|
||||||
host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`).
|
host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`).
|
||||||
- **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via
|
- **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"index",
|
"index",
|
||||||
"how-it-works",
|
"how-it-works",
|
||||||
|
"security",
|
||||||
"quickstart",
|
"quickstart",
|
||||||
"install",
|
"install",
|
||||||
"---Host Setup---",
|
"---Host Setup---",
|
||||||
|
|||||||
@@ -5,16 +5,20 @@ description: From nothing to streaming — set up a host and connect your first
|
|||||||
|
|
||||||
This is the shortest path to a working stream. Each step links to the details.
|
This is the shortest path to a working stream. Each step links to the details.
|
||||||
|
|
||||||
|
> A streaming host is remote control of the machine, so it's built for **trusted local networks** — keep
|
||||||
|
> it on your LAN or a VPN and don't expose it to the internet. Two minutes on
|
||||||
|
> [Security & Safe Use](/docs/security) before you start is worth it.
|
||||||
|
|
||||||
## 1. Set up the host
|
## 1. Set up the host
|
||||||
|
|
||||||
On your Linux + NVIDIA machine, follow the guide for your system:
|
On your Linux gaming machine (NVIDIA, AMD, or Intel GPU), follow the guide for your system:
|
||||||
|
|
||||||
- [Ubuntu — GNOME](/docs/ubuntu-gnome)
|
- [Ubuntu — GNOME](/docs/ubuntu-gnome)
|
||||||
- [Ubuntu — KDE Plasma](/docs/ubuntu-kde)
|
- [Ubuntu — KDE Plasma](/docs/ubuntu-kde)
|
||||||
- [Fedora — KDE Plasma](/docs/fedora-kde)
|
- [Fedora — KDE Plasma](/docs/fedora-kde)
|
||||||
- [Bazzite — gamescope / Steam](/docs/bazzite)
|
- [Bazzite — gamescope / Steam](/docs/bazzite)
|
||||||
|
|
||||||
Each one covers the NVIDIA driver, the dependencies, and how to build and run the host. Check the
|
Each one covers the GPU driver, the dependencies, and how to build and run the host. Check the
|
||||||
[Requirements](/docs/requirements) first if you're not sure your machine is a fit.
|
[Requirements](/docs/requirements) first if you're not sure your machine is a fit.
|
||||||
|
|
||||||
## 2. Start the host
|
## 2. Start the host
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ environments it supports today, each with its own guide:
|
|||||||
Other wlroots compositors (Sway/Hyprland) also work but aren't a primary target. If your desktop isn't
|
Other wlroots compositors (Sway/Hyprland) also work but aren't a primary target. If your desktop isn't
|
||||||
listed, the host still needs one of these compositor backends to create a virtual display.
|
listed, the host still needs one of these compositor backends to create a virtual display.
|
||||||
|
|
||||||
> **Windows host:** punktfunk also runs as a native host on **Windows 10/11 (x64)** — a signed
|
> **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
|
||||||
> installer that registers a service and bundles a virtual-display driver. It encodes on NVIDIA
|
> signed installer that registers a service and bundles a virtual-display driver (whose driver-
|
||||||
|
> framework needs make 22H2 the hard floor — Windows 10 is not supported). It encodes on NVIDIA
|
||||||
> (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see
|
> (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see
|
||||||
> [Windows Host](/docs/windows-host).
|
> [Windows Host](/docs/windows-host).
|
||||||
|
|
||||||
@@ -63,10 +64,16 @@ Minimum compositor versions (newer is fine):
|
|||||||
## Network
|
## Network
|
||||||
|
|
||||||
- Host and client on the **same network** — a LAN, or a VPN that puts them on one subnet. punktfunk
|
- Host and client on the **same network** — a LAN, or a VPN that puts them on one subnet. punktfunk
|
||||||
assumes a trusted local network; it's not built to be exposed to the public internet.
|
assumes a trusted local network; it's **not built to be exposed to the public internet — don't
|
||||||
|
port-forward it.** To stream from outside your home, use a VPN so the remote client is on the same
|
||||||
|
private subnet.
|
||||||
- For best results, a wired or fast Wi-Fi link. The host can run a built-in **speed test** to pick a
|
- For best results, a wired or fast Wi-Fi link. The host can run a built-in **speed test** to pick a
|
||||||
bitrate for your link (see [Configuration](/docs/configuration)).
|
bitrate for your link (see [Configuration](/docs/configuration)).
|
||||||
|
|
||||||
|
> **Before you set up a host, read [Security & Safe Use](/docs/security).** A streaming host is
|
||||||
|
> remote control of the machine — it's important to understand what that exposes, why to keep it on a
|
||||||
|
> trusted network, and how pairing protects you.
|
||||||
|
|
||||||
## A client
|
## A client
|
||||||
|
|
||||||
You also need something to stream *to* — see [Connect a Client](/docs/clients). There are native
|
You also need something to stream *to* — see [Connect a Client](/docs/clients). There are native
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ session unit — see [Bazzite](/docs/bazzite).
|
|||||||
|
|
||||||
On Windows the host runs as a `LocalSystem` service that launches into the interactive session, so it
|
On Windows the host runs as a `LocalSystem` service that launches into the interactive session, so it
|
||||||
captures the secure desktop (UAC / lock screen) and survives reboots with nobody logged in — the same
|
captures the secure desktop (UAC / lock screen) and survives reboots with nobody logged in — the same
|
||||||
model Sunshine/Apollo use.
|
model Sunshine/Apollo use. Because it runs at that privilege level, keep it on a trusted network and be
|
||||||
|
deliberate about which machine you host on — see [Security & Safe Use](/docs/security).
|
||||||
|
|
||||||
The easy path is the **signed installer**: download `punktfunk-host-setup-<ver>.exe` from the package
|
The easy path is the **signed installer**: download `punktfunk-host-setup-<ver>.exe` from the package
|
||||||
registry ([`punktfunk-host-windows`](https://git.unom.io/unom/-/packages)) and run it. It drops the host
|
registry ([`punktfunk-host-windows`](https://git.unom.io/unom/-/packages)) and run it. It drops the host
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
---
|
||||||
|
title: Security & Safe Use
|
||||||
|
description: What a streaming host actually exposes, why to keep it on a trusted network, and how punktfunk protects you.
|
||||||
|
---
|
||||||
|
|
||||||
|
Read this before you put a host on a network you don't fully control. punktfunk is built to be secure
|
||||||
|
**on a trusted local network**, and that's the setting we support today. This page is upfront about what
|
||||||
|
a streaming host is, what protects it, and where the honest limits are.
|
||||||
|
|
||||||
|
> **The short version**
|
||||||
|
> - **Keep the host on a network you trust** — your home LAN, or a private VPN that puts host and client
|
||||||
|
> on the same subnet. **Do not port-forward it to the public internet.**
|
||||||
|
> - **A streaming host is remote control of the machine.** Anyone who can stream to it sees the screen
|
||||||
|
> and can move the mouse, type, and act as a controller — the same as sitting at the keyboard.
|
||||||
|
> - **Pairing is the security boundary.** Require pairing (the default), pick a strong console
|
||||||
|
> password, and review your paired devices from time to time.
|
||||||
|
> - **Be thoughtful about *which* machine you run it on** — especially on Windows, where the host runs
|
||||||
|
> with high system privileges so it can do its job. Prefer a dedicated or gaming PC over one holding
|
||||||
|
> your most sensitive data.
|
||||||
|
|
||||||
|
## What a streaming host really is
|
||||||
|
|
||||||
|
Low-latency desktop and game streaming means two things travel over the network: **the screen goes
|
||||||
|
out, and input comes back in.** A paired client doesn't just watch — it drives. Its mouse, keyboard,
|
||||||
|
and controller are injected into the host's desktop, so **for anything it can reach, a streaming client
|
||||||
|
is equivalent to a person sitting at that machine.**
|
||||||
|
|
||||||
|
That's the feature. It's also the risk to understand:
|
||||||
|
|
||||||
|
- The host can capture the **secure desktop** — UAC elevation prompts and the lock screen — so a
|
||||||
|
connected client can see and interact with those too. (This is what lets you unlock and administer a
|
||||||
|
headless box remotely; it's the same capability Sunshine and Apollo provide.)
|
||||||
|
- Injected input isn't sandboxed to a game. Whoever is streaming can alt-tab, open a terminal, read
|
||||||
|
files, or change settings — whatever the logged-in session can do.
|
||||||
|
|
||||||
|
This is true of **every** remote-access and game-streaming tool, not just punktfunk. The takeaway isn't
|
||||||
|
"don't use it" — it's "treat access to your host the way you'd treat handing someone your unlocked
|
||||||
|
keyboard." The rest of this page is about making sure only people you intend can get that access.
|
||||||
|
|
||||||
|
## Keep it on a trusted network
|
||||||
|
|
||||||
|
**punktfunk assumes a trusted local network. It is not designed, tested, or hardened to be exposed to
|
||||||
|
the public internet — do not port-forward it.** There is no WAN-hardening story yet: no rate-limited
|
||||||
|
public authentication gateway, no DDoS protection, no assumption that hostile traffic is constantly
|
||||||
|
probing the ports. Exposing the streaming ports directly to the internet puts an interactive
|
||||||
|
control surface for your machine in front of the entire world.
|
||||||
|
|
||||||
|
If you want to stream from outside your home, tunnel in instead of opening up:
|
||||||
|
|
||||||
|
- **Use a VPN** — WireGuard, Tailscale, or your router's built-in VPN. This puts your remote client on
|
||||||
|
the *same private subnet* as the host, so from punktfunk's point of view it's still a local
|
||||||
|
connection, and the tunnel (not punktfunk) handles internet-facing authentication and encryption.
|
||||||
|
Discovery, pairing, and streaming then work exactly as they do at home.
|
||||||
|
- **Don't** map a router port to the host. A port-forward turns "trusted LAN service" into
|
||||||
|
"internet-facing service" with none of the protections that implies.
|
||||||
|
|
||||||
|
A note for **portable machines**: the installer opens the streaming ports on the firewall for *all*
|
||||||
|
network profiles, including Public. That's convenient at home but means that if you take a laptop host
|
||||||
|
onto an untrusted network — a café, a hotel, a conference — other devices on that network can reach the
|
||||||
|
ports and attempt to pair. Pairing still protects you (an attacker who doesn't know the PIN can't get
|
||||||
|
in), but the safest habit is to stop the host service, or firewall it off, when you're on a network you
|
||||||
|
don't control.
|
||||||
|
|
||||||
|
## What actually protects you
|
||||||
|
|
||||||
|
punktfunk has **no accounts and no cloud**. Trust is established directly, device-to-device, and then
|
||||||
|
pinned. The layers, from the outside in:
|
||||||
|
|
||||||
|
- **Pairing is required by default.** A new device can't stream until it completes a one-time
|
||||||
|
**PIN pairing ceremony** (SPAKE2): the host shows a 4-digit PIN, you enter it on the client, and the
|
||||||
|
exchange cryptographically binds both identities. An attacker who doesn't know the PIN gets a
|
||||||
|
*single online guess* — no offline cracking, no dictionary attack. See
|
||||||
|
[Pairing & Trust](/docs/pairing).
|
||||||
|
- **Identities are pinned.** After pairing, the client remembers the host's certificate fingerprint and
|
||||||
|
the host stores the client's. Reconnects are automatic and mutually authenticated; if a host's
|
||||||
|
fingerprint ever changes, the client refuses to auto-trust it and forces re-pairing.
|
||||||
|
- **The admin surface is loopback-only.** The management API's read-only status is reachable by paired
|
||||||
|
clients over the LAN (authenticated by their certificate), but every state-changing action — arming
|
||||||
|
pairing, removing devices, session control — is honored **only from the local machine** (the web
|
||||||
|
console connects over loopback). It is never exposed to the network.
|
||||||
|
- **The web console has its own password.** On Windows it's set during install (a strong random default)
|
||||||
|
and stored readable only by Administrators and SYSTEM.
|
||||||
|
|
||||||
|
**GameStream / Moonlight compatibility is the weak-crypto path — trusted LAN only.** To interoperate
|
||||||
|
with stock Moonlight clients, punktfunk can speak the legacy GameStream protocol, which pairs over
|
||||||
|
plain HTTP and uses older encryption. It is **opt-in** (`serve --gamestream`) and appropriate only on a
|
||||||
|
network you fully trust. The default native `punktfunk/1` protocol is the secure path (modern AEAD
|
||||||
|
crypto, pinned identities); leave GameStream off unless you specifically need Moonlight.
|
||||||
|
|
||||||
|
## Choosing which machine to host on
|
||||||
|
|
||||||
|
We've put real work into hardening the host — sealed capture and gamepad channels, no kernel drivers,
|
||||||
|
loopback-gated admin, pinned trust — and we'll keep at it. But security is also about *blast radius*:
|
||||||
|
if a host is ever compromised, or you misconfigure trust, what does the attacker get? So pick the
|
||||||
|
machine with that in mind.
|
||||||
|
|
||||||
|
### The Windows host runs with high privileges
|
||||||
|
|
||||||
|
To capture the secure desktop (UAC, lock screen) and stream across reboots with nobody logged in, the
|
||||||
|
Windows host installs a service that runs as **`LocalSystem` (SYSTEM)** — the highest local privilege on
|
||||||
|
Windows. This is the same design Sunshine and Apollo use, and it's what makes headless, log-in-optional
|
||||||
|
streaming possible. It also means the host is a high-value component: a compromise of the host, or a
|
||||||
|
device you paired that you shouldn't have, is a foothold at the most powerful level of that machine.
|
||||||
|
|
||||||
|
We mitigate this deliberately:
|
||||||
|
|
||||||
|
- **Zero kernel drivers.** The virtual display and all three virtual gamepads are **user-mode (UMDF)**
|
||||||
|
drivers, so a driver bug is contained to a restricted service account — never ring-0, never
|
||||||
|
full-system. (This is why punktfunk dropped ViGEmBus.)
|
||||||
|
- **Sealed internal channels.** The desktop-frame ring and the gamepad input/output channels are
|
||||||
|
passed between the host and its drivers as duplicated handles to unnamed objects, so another local
|
||||||
|
service can't open them by name to read your screen or forge controller input. (Details:
|
||||||
|
[`idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md)
|
||||||
|
and [`gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md).)
|
||||||
|
- **Secrets are locked down.** The management token, the host identity key, and the console password
|
||||||
|
are stored with Administrators/SYSTEM-only permissions.
|
||||||
|
|
||||||
|
**The honest floor still applies.** None of this defends against an attacker who is *already* an
|
||||||
|
administrator or SYSTEM on the box — at that level they own the machine regardless of punktfunk. And a
|
||||||
|
virtual display is a real monitor: any process already running in your desktop session can capture it
|
||||||
|
through the ordinary OS screen-capture APIs, exactly as it could capture a physical monitor. That floor
|
||||||
|
is the same for every virtual-display streaming stack.
|
||||||
|
|
||||||
|
**Recommendation:** run the Windows host on a **dedicated or gaming PC**, not on a machine that also
|
||||||
|
holds your most sensitive material (work laptop, financial records, the box with your password vault).
|
||||||
|
A gaming rig you stream from is a great fit; your primary secrets machine is not.
|
||||||
|
|
||||||
|
### The Linux host runs as your desktop user
|
||||||
|
|
||||||
|
The Linux host runs inside your normal desktop session as your **regular user account**, not root — so a
|
||||||
|
worst-case compromise is scoped to that user rather than the whole system. The same network guidance
|
||||||
|
applies: keep it on a trusted LAN or a VPN, require pairing, and don't expose it to the internet.
|
||||||
|
|
||||||
|
## A short hardening checklist
|
||||||
|
|
||||||
|
- **Require pairing** — it's the default; don't run `--open` / `--allow-tofu` except on a network you
|
||||||
|
fully trust and control.
|
||||||
|
- **Use a strong console password** and keep it out of shared documents.
|
||||||
|
- **Stay on a trusted network** — LAN or VPN. Never port-forward to the internet.
|
||||||
|
- **Leave GameStream off** unless you specifically need Moonlight compatibility.
|
||||||
|
- **Review paired devices** in the web console periodically; remove anything you don't recognize.
|
||||||
|
- **Keep the host updated** — security fixes ship in new builds.
|
||||||
|
- **On portable hosts**, stop the service when you're on an untrusted network.
|
||||||
|
|
||||||
|
## For the technically curious
|
||||||
|
|
||||||
|
The deeper security design lives in the repository, and it's candid about residual limits:
|
||||||
|
|
||||||
|
- [`design/idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md) — the sealed frame channel (why the Windows capture path is isolated), and its honest floor.
|
||||||
|
- [`design/gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md) — the sealed gamepad channel.
|
||||||
|
- [`design/security-review-2026-06-28.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review-2026-06-28.md) and [`design/security-review.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review.md) — the standing security reviews.
|
||||||
|
|
||||||
|
Found a security issue? Please report it privately rather than opening a public issue.
|
||||||
@@ -12,6 +12,9 @@ desktop-class SteamOS box is a natural always-on streaming host. The **Steam Dec
|
|||||||
device we can test on today, so it's what these instructions are validated against; the same
|
device we can test on today, so it's what these instructions are validated against; the same
|
||||||
on-device build works on any SteamOS 3 system.
|
on-device build works on any SteamOS 3 system.
|
||||||
|
|
||||||
|
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||||
|
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||||
|
|
||||||
SteamOS is an immutable, read-only Arch base, so the host isn't a system package. Instead a single
|
SteamOS is an immutable, read-only Arch base, so the host isn't a system package. Instead a single
|
||||||
script builds the host **natively inside a Debian-trixie distrobox** (ABI-matched to SteamOS's
|
script builds the host **natively inside a Debian-trixie distrobox** (ABI-matched to SteamOS's
|
||||||
FFmpeg/glibc — the binary then runs natively on SteamOS) and wires it up as systemd user services.
|
FFmpeg/glibc — the binary then runs natively on SteamOS) and wires it up as systemd user services.
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ Then log out and back in. On other distros this is `sudo usermod -aG input $USER
|
|||||||
concurrent native sessions (up to 4 by default); heavy load is usually bitrate-bound, so
|
concurrent native sessions (up to 4 by default); heavy load is usually bitrate-bound, so
|
||||||
lower the bitrate first.
|
lower the bitrate first.
|
||||||
|
|
||||||
|
## Windows: "punktfunk Virtual Display" shows Code 10 in Device Manager
|
||||||
|
|
||||||
|
Sessions end with *"pf-vdisplay driver interface not found"* and Device Manager shows the
|
||||||
|
**punktfunk Virtual Display** device failed with **Code 10** (`STATUS_DEVICE_POWER_FAILURE`).
|
||||||
|
|
||||||
|
This means your Windows version is too old. The virtual-display driver requires the **IddCx 1.10**
|
||||||
|
driver framework, which first shipped in **Windows 11 22H2 (build 22621)** — on Windows 10
|
||||||
|
(including LTSC) and Windows 11 21H2 the driver installs but cannot start. Reinstalling won't help;
|
||||||
|
the fix is updating to Windows 11 22H2 or newer. (Current installers refuse to run on older
|
||||||
|
Windows for this reason; if you see this, the host was likely installed with an older installer.)
|
||||||
|
|
||||||
## Still stuck?
|
## Still stuck?
|
||||||
|
|
||||||
Run the host with `RUST_LOG=info` (or `debug`) and check `journalctl --user -u punktfunk-host` for the
|
Run the host with `RUST_LOG=info` (or `debug`) and check `journalctl --user -u punktfunk-host` for the
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ description: Set up a punktfunk host on Ubuntu with the GNOME desktop (Mutter).
|
|||||||
Set up a punktfunk host on **Ubuntu** (Desktop or Server) running **GNOME**. The host uses GNOME's
|
Set up a punktfunk host on **Ubuntu** (Desktop or Server) running **GNOME**. The host uses GNOME's
|
||||||
Mutter compositor to create a per-client virtual display. Tested on Ubuntu 24.04+ and GNOME 48+.
|
Mutter compositor to create a per-client virtual display. Tested on Ubuntu 24.04+ and GNOME 48+.
|
||||||
|
|
||||||
> New to this? Skim [Requirements](/docs/requirements) first.
|
> New to this? Skim [Requirements](/docs/requirements) first, and read
|
||||||
|
> [Security & Safe Use](/docs/security) — a streaming host is remote control of the machine, so keep it
|
||||||
|
> on a trusted LAN or VPN and require pairing.
|
||||||
|
|
||||||
## 1. NVIDIA driver
|
## 1. NVIDIA driver
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ description: Set up a punktfunk host on Ubuntu with KDE Plasma (KWin).
|
|||||||
Set up a punktfunk host on **Ubuntu** running **KDE Plasma**. The host uses KDE's KWin compositor to
|
Set up a punktfunk host on **Ubuntu** running **KDE Plasma**. The host uses KDE's KWin compositor to
|
||||||
create a per-client virtual display. Needs **KWin 6.5.6 or newer**.
|
create a per-client virtual display. Needs **KWin 6.5.6 or newer**.
|
||||||
|
|
||||||
> New to this? Skim [Requirements](/docs/requirements) first.
|
> New to this? Skim [Requirements](/docs/requirements) first, and read
|
||||||
|
> [Security & Safe Use](/docs/security) — a streaming host is remote control of the machine, so keep it
|
||||||
|
> on a trusted LAN or VPN and require pairing.
|
||||||
|
|
||||||
## 1. NVIDIA driver
|
## 1. NVIDIA driver
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: "Windows Host"
|
|||||||
description: "Run the Punktfunk streaming host on a Windows PC — a first-class, all-vendor, virtual-display host."
|
description: "Run the Punktfunk streaming host on a Windows PC — a first-class, all-vendor, virtual-display host."
|
||||||
---
|
---
|
||||||
|
|
||||||
Set up a Punktfunk host on a **Windows 10/11 PC** and stream its desktop or games to any Punktfunk or
|
Set up a Punktfunk host on a **Windows 11 PC (22H2 or newer)** and stream its desktop or games to any Punktfunk or
|
||||||
[Moonlight](/docs/moonlight) client. A signed installer registers a Windows service that streams at the
|
[Moonlight](/docs/moonlight) client. A signed installer registers a Windows service that streams at the
|
||||||
client's **exact resolution and refresh** via Punktfunk's own **virtual display** — including
|
client's **exact resolution and refresh** via Punktfunk's own **virtual display** — including
|
||||||
**HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR mode. The virtual display is created
|
**HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR mode. The virtual display is created
|
||||||
@@ -12,13 +12,22 @@ the secure desktop (UAC prompts, the lock screen).
|
|||||||
|
|
||||||
> New to this? Skim [Requirements](/docs/requirements) first.
|
> New to this? Skim [Requirements](/docs/requirements) first.
|
||||||
|
|
||||||
|
> **Read [Security & Safe Use](/docs/security) before you set this up.** The Windows host runs as a
|
||||||
|
> `LocalSystem` service (so it can capture the secure desktop and stream headless), which makes it a
|
||||||
|
> high-privilege component — keep it on a trusted network, never expose it to the internet, and prefer
|
||||||
|
> a dedicated or gaming PC over a machine that holds your most sensitive data.
|
||||||
|
|
||||||
> This page is about the Windows **host** — streaming *from* a Windows PC. To stream *to* a Windows PC,
|
> This page is about the Windows **host** — streaming *from* a Windows PC. To stream *to* a Windows PC,
|
||||||
> see the [Windows client](/docs/clients#windows-desktop-client).
|
> see the [Windows client](/docs/clients#windows-desktop-client).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Windows 10 or 11, x64.** ARM64 is not built (no ARM64 NVIDIA driver, and the virtual-display
|
- **Windows 11 22H2 (build 22621) or newer, x64.** Windows 10 — including LTSC — and Windows 11
|
||||||
driver is x64-only).
|
21H2 are **not supported**: the virtual-display driver needs the IddCx 1.10 driver framework,
|
||||||
|
which first shipped in Windows 11 22H2. On older Windows the driver installs but can't start
|
||||||
|
("punktfunk Virtual Display" shows **Code 10** in Device Manager and streaming fails); the
|
||||||
|
installer therefore refuses to run there. ARM64 is not built either (no ARM64 NVIDIA driver, and
|
||||||
|
the virtual-display driver is x64-only).
|
||||||
- **A GPU for hardware encode** — the host auto-detects the vendor:
|
- **A GPU for hardware encode** — the host auto-detects the vendor:
|
||||||
- **NVIDIA** → NVENC
|
- **NVIDIA** → NVENC
|
||||||
- **AMD** → AMF
|
- **AMD** → AMF
|
||||||
@@ -96,6 +105,13 @@ prompts, the lock screen) and keep streaming across reboots with nobody logged i
|
|||||||
Sunshine and Apollo use. Service registration, firewall rules, and the supervisor all live in
|
Sunshine and Apollo use. Service registration, firewall rules, and the supervisor all live in
|
||||||
`punktfunk-host service install`; the installer just lays the exe down and calls it elevated.
|
`punktfunk-host service install`; the installer just lays the exe down and calls it elevated.
|
||||||
|
|
||||||
|
Running as SYSTEM is what makes headless, log-in-optional streaming work — and it's why the host is a
|
||||||
|
high-privilege component worth being deliberate about. punktfunk mitigates this with **zero kernel
|
||||||
|
drivers** (the virtual display and gamepads are user-mode UMDF drivers), **sealed internal channels**
|
||||||
|
between the host and its drivers, and Administrators/SYSTEM-only permissions on its secrets. See
|
||||||
|
[Security & Safe Use](/docs/security) for the full picture, including why we recommend not hosting on
|
||||||
|
your most sensitive machine.
|
||||||
|
|
||||||
### One core, Windows backends
|
### One core, Windows backends
|
||||||
|
|
||||||
Most of Punktfunk is platform-agnostic. `punktfunk-core` (protocol, FEC, crypto, session, transport,
|
Most of Punktfunk is platform-agnostic. `punktfunk-core` (protocol, FEC, crypto, session, transport,
|
||||||
|
|||||||
+12
-1
@@ -50,7 +50,7 @@ build() {
|
|||||||
# The host's zero-copy FFI link-needs libcuda at build time; nvidia-utils provides it on an
|
# The host's zero-copy FFI link-needs libcuda at build time; nvidia-utils provides it on an
|
||||||
# NVIDIA builder. On a GPU-less builder symlink the CUDA stub into the link path first (same
|
# NVIDIA builder. On a GPU-less builder symlink the CUDA stub into the link path first (same
|
||||||
# caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/
|
# caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/
|
||||||
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux
|
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux -p punktfunk-tray
|
||||||
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
||||||
# built AND run with bun.
|
# built AND run with bun.
|
||||||
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||||
@@ -95,6 +95,17 @@ package_punktfunk-host() {
|
|||||||
# connect). See the file's header comment.
|
# connect). See the file's header comment.
|
||||||
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \
|
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \
|
||||||
"$pkgdir/usr/share/applications/io.unom.Punktfunk.Host.desktop"
|
"$pkgdir/usr/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||||
|
# Status tray: per-user SNI icon + XDG autostart entry (self-gating: --autostart exits silently
|
||||||
|
# for users who don't run a host) + the hicolor status icons it names.
|
||||||
|
install -Dm0755 "$T/punktfunk-tray" "$pkgdir/usr/bin/punktfunk-tray"
|
||||||
|
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Tray.desktop" \
|
||||||
|
"$pkgdir/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop"
|
||||||
|
local sz png
|
||||||
|
for sz in 22x22 48x48; do
|
||||||
|
for png in "$R"/packaging/linux/icons/hicolor/$sz/apps/*.png; do
|
||||||
|
install -Dm0644 "$png" "$pkgdir/usr/share/icons/hicolor/$sz/apps/$(basename "$png")"
|
||||||
|
done
|
||||||
|
done
|
||||||
# headless session helpers + env templates + OpenAPI doc
|
# headless session helpers + env templates + OpenAPI doc
|
||||||
install -Dm0755 "$R/scripts/headless/run-headless-kde.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-kde.sh"
|
install -Dm0755 "$R/scripts/headless/run-headless-kde.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-kde.sh"
|
||||||
install -Dm0755 "$R/scripts/headless/run-headless-sway.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-sway.sh"
|
install -Dm0755 "$R/scripts/headless/run-headless-sway.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-sway.sh"
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ if [ ! -x "$BIN" ]; then
|
|||||||
echo "==> building $PKG (release)"
|
echo "==> building $PKG (release)"
|
||||||
PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs)
|
PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs)
|
||||||
fi
|
fi
|
||||||
|
TRAY_BIN="target/release/punktfunk-tray"
|
||||||
|
if [ ! -x "$TRAY_BIN" ]; then
|
||||||
|
echo "==> building punktfunk-tray (release)"
|
||||||
|
cargo build --release -p punktfunk-tray --locked
|
||||||
|
fi
|
||||||
|
|
||||||
STAGE="$(mktemp -d)"
|
STAGE="$(mktemp -d)"
|
||||||
trap 'rm -rf "$STAGE"' EXIT
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
@@ -57,6 +62,16 @@ sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk
|
|||||||
# connect, so it has to be present before the host ever connects. See the file's header comment.
|
# connect, so it has to be present before the host ever connects. See the file's header comment.
|
||||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
|
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
|
||||||
"$STAGE/usr/share/applications/io.unom.Punktfunk.Host.desktop"
|
"$STAGE/usr/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||||
|
# Status tray: the per-user SNI icon + its XDG autostart entry (self-gating: --autostart exits
|
||||||
|
# silently for users who don't run a host) + the hicolor status icons it names.
|
||||||
|
install -Dm0755 "$TRAY_BIN" "$STAGE/usr/bin/punktfunk-tray"
|
||||||
|
install -Dm0644 packaging/linux/io.unom.Punktfunk.Tray.desktop \
|
||||||
|
"$STAGE/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop"
|
||||||
|
for sz in 22x22 48x48; do
|
||||||
|
for png in packaging/linux/icons/hicolor/$sz/apps/*.png; do
|
||||||
|
install -Dm0644 "$png" "$STAGE/usr/share/icons/hicolor/$sz/apps/$(basename "$png")"
|
||||||
|
done
|
||||||
|
done
|
||||||
install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
|
install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
|
||||||
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh"
|
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh"
|
||||||
install -Dm0644 scripts/headless/kde-authorized "$SHAREDIR/headless/kde-authorized"
|
install -Dm0644 scripts/headless/kde-authorized "$SHAREDIR/headless/kde-authorized"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 473 B |
Binary file not shown.
|
After Width: | Height: | Size: 485 B |
Binary file not shown.
|
After Width: | Height: | Size: 474 B |
Binary file not shown.
|
After Width: | Height: | Size: 483 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user