Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af13f0b749 | |||
| d285d4a0b2 | |||
| 04f370999c | |||
| 2c937855b3 | |||
| 8005b11faf | |||
| 01fcb01019 | |||
| 95a08e99c3 | |||
| a3e1ea2b44 | |||
| 6686fcdded |
@@ -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
|
||||||
|
|
||||||
@@ -447,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)
|
||||||
@@ -454,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)
|
||||||
|
|||||||
@@ -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.1"
|
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.1"
|
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.1"
|
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.1"
|
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.1"
|
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.1"
|
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.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -2887,7 +3033,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -2899,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"
|
||||||
@@ -5221,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",
|
||||||
|
|||||||
@@ -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.1"
|
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,7 +49,7 @@ 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 |
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.1"
|
"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.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
# Transient-fault retries: googleapis.com occasionally drops the TLS session ("EOF
|
||||||
|
# occurred in violation of protocol" — failed two release uploads on 2026-07-02) or
|
||||||
|
# answers 5xx. Retry those with backoff; 4xx raises immediately (a real API error).
|
||||||
|
# The edits API is transactional until commit, so re-sending any of these is safe.
|
||||||
|
last = None
|
||||||
|
for attempt in range(4):
|
||||||
|
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)
|
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=300) as r:
|
with urllib.request.urlopen(req, timeout=300) as r:
|
||||||
body = r.read()
|
body = r.read()
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
|
|
||||||
return json.loads(body) if (want_json and body) else body
|
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():
|
||||||
|
|||||||
@@ -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..] {
|
||||||
|
|||||||
@@ -232,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),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
&HSTRING::from(event_name(target.target_id)),
|
|
||||||
)
|
|
||||||
.context("CreateEvent(IDD-push)")?;
|
.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,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Shm {
|
/// Build a `SECURITY_ATTRIBUTES` from an SDDL literal (`psd` is OS-allocated and leaked — acceptable
|
||||||
/// Create + zero a `size`-byte section named `name`, mapped read/write. The section handle is owned
|
/// for the handful of pad channels a host creates; it must outlive the returned `SECURITY_ATTRIBUTES`).
|
||||||
/// immediately, so any failure below (or the returned `Shm`'s drop) closes it.
|
fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> {
|
||||||
pub(super) fn create(name: &HSTRING, size: usize) -> Result<Shm> {
|
|
||||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||||
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (freed at process
|
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (leaked — see above).
|
||||||
// exit — acceptable for a host-lifetime object).
|
|
||||||
unsafe {
|
unsafe {
|
||||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"),
|
sddl,
|
||||||
SDDL_REVISION_1,
|
SDDL_REVISION_1,
|
||||||
&mut psd,
|
&mut psd,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
let sa = SECURITY_ATTRIBUTES {
|
Ok(SECURITY_ATTRIBUTES {
|
||||||
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
lpSecurityDescriptor: psd.0,
|
lpSecurityDescriptor: psd.0,
|
||||||
bInheritHandle: false.into(),
|
bInheritHandle: false.into(),
|
||||||
};
|
})
|
||||||
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the SDDL above.
|
}
|
||||||
|
|
||||||
|
impl Shm {
|
||||||
|
/// Create + zero an **unnamed** `size`-byte section, mapped read/write — the sealed DATA section.
|
||||||
|
/// SDDL `D:P(A;;GA;;;SY)` (SYSTEM-only, protected): with no name there is nothing to enumerate,
|
||||||
|
/// open, or squat, and the driver reaches it through a duplicated handle, which carries the
|
||||||
|
/// source's access without re-checking the object DACL (the exact property the frame ring
|
||||||
|
/// validated on-glass — `design/idd-push-security.md`).
|
||||||
|
pub(super) fn create_unnamed(size: usize) -> Result<Shm> {
|
||||||
|
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?;
|
||||||
|
Self::create_inner(&sa, PCWSTR::null(), size).context("create unnamed gamepad DATA section")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL
|
||||||
|
/// `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.
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"bootstrap mailbox {name} already exists and stayed alive across retries — another \
|
||||||
|
punktfunk-host instance is serving this pad index, or a local service is squatting the \
|
||||||
|
name (gamepad DoS attempt?)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 485 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 856 B |
|
After Width: | Height: | Size: 868 B |
|
After Width: | Height: | Size: 859 B |
|
After Width: | Height: | Size: 867 B |
|
After Width: | Height: | Size: 866 B |
@@ -0,0 +1,15 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=punktfunk host status
|
||||||
|
Comment=Tray icon showing the punktfunk host service status
|
||||||
|
# --autostart exits silently unless this user actually runs a host (~/.config/punktfunk exists or
|
||||||
|
# the punktfunk-host user unit is enabled) — the package installs this for every desktop user.
|
||||||
|
Exec=/usr/bin/punktfunk-tray --autostart
|
||||||
|
Icon=punktfunk-tray
|
||||||
|
# Autostart-only: not a launcher entry (launch it from a terminal as `punktfunk-tray` if wanted).
|
||||||
|
NoDisplay=true
|
||||||
|
# KDE: start after plasmashell so the StatusNotifierWatcher is up (harmless elsewhere; the tray
|
||||||
|
# also waits for the watcher when started early).
|
||||||
|
X-KDE-autostart-after=panel
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
Categories=Network;Utility;
|
||||||
@@ -167,7 +167,7 @@ export RUSTUP_TOOLCHAIN=stable
|
|||||||
# Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it).
|
# Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it).
|
||||||
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
||||||
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
|
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
|
||||||
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
|
||||||
|
|
||||||
%if %{with web}
|
%if %{with web}
|
||||||
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
||||||
@@ -211,6 +211,17 @@ sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#%{_datadir}/%{name}/
|
|||||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
|
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
|
||||||
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
|
%{buildroot}%{_datadir}/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 target/release/punktfunk-tray %{buildroot}%{_bindir}/punktfunk-tray
|
||||||
|
install -Dm0644 packaging/linux/io.unom.Punktfunk.Tray.desktop \
|
||||||
|
%{buildroot}%{_sysconfdir}/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" %{buildroot}%{_datadir}/icons/hicolor/$sz/apps/"$(basename "$png")"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
# --- client subpackage ---
|
# --- client subpackage ---
|
||||||
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
|
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
|
||||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
|
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
|
||||||
@@ -275,11 +286,14 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
|||||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||||
%doc README.md design/implementation-plan.md packaging/README.md
|
%doc README.md design/implementation-plan.md packaging/README.md
|
||||||
%{_bindir}/punktfunk-host
|
%{_bindir}/punktfunk-host
|
||||||
|
%{_bindir}/punktfunk-tray
|
||||||
%{_udevrulesdir}/60-punktfunk.rules
|
%{_udevrulesdir}/60-punktfunk.rules
|
||||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||||
%{_userunitdir}/punktfunk-host.service
|
%{_userunitdir}/punktfunk-host.service
|
||||||
%{_userunitdir}/punktfunk-kde-session.service
|
%{_userunitdir}/punktfunk-kde-session.service
|
||||||
%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
|
%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
|
||||||
|
%{_sysconfdir}/xdg/autostart/io.unom.Punktfunk.Tray.desktop
|
||||||
|
%{_datadir}/icons/hicolor/*/apps/punktfunk-tray*.png
|
||||||
%dir /etc/gamescope-session-plus
|
%dir /etc/gamescope-session-plus
|
||||||
%dir /etc/gamescope-session-plus/sessions.d
|
%dir /etc/gamescope-session-plus/sessions.d
|
||||||
%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam
|
%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ generic package registry (`punktfunk-host-windows`) by `.gitea/workflows/windows
|
|||||||
|
|
||||||
> Full picture (drivers-from-source, toolchain, CI, dev loop): **[`design/windows-build-and-packaging.md`](../../design/windows-build-and-packaging.md)**. This README is the `packaging/windows/` file index.
|
> Full picture (drivers-from-source, toolchain, CI, dev loop): **[`design/windows-build-and-packaging.md`](../../design/windows-build-and-packaging.md)**. This README is the `packaging/windows/` file index.
|
||||||
|
|
||||||
|
## Windows 11 22H2+ only (no Windows 10)
|
||||||
|
|
||||||
|
The installer refuses anything below **Windows 11 22H2 (build 22621)** — `MinVersion=10.0.22621` in
|
||||||
|
`punktfunk-host.iss`, with a `[Messages]` override naming the requirement. The floor comes from the
|
||||||
|
**pf-vdisplay** driver: it is built against the **IddCx 1.10** class extension (the HDR `*2` DDIs +
|
||||||
|
the FP16 adapter cap, linked via the 1.10 `IddCxStub`, no runtime `IddCxGetVersion` downgrade), and
|
||||||
|
IddCx 1.10 first shipped in Windows 11 22H2. On older Windows — **all of Windows 10 including LTSC,
|
||||||
|
and Windows 11 21H2** — the driver *package* installs fine, but the device then fails to start with
|
||||||
|
**Code 10 `STATUS_DEVICE_POWER_FAILURE`** in Device Manager and every session dies with "pf-vdisplay
|
||||||
|
driver interface not found". Gating the installer turns that late, confusing failure into an upfront
|
||||||
|
message. (Down-level SDR-only support would need a runtime IddCx version check in the driver —
|
||||||
|
tracked as a possible future feature, not planned.)
|
||||||
|
|
||||||
## x64 only (no ARM64)
|
## x64 only (no ARM64)
|
||||||
|
|
||||||
Unlike the client (which ships x64 + ARM64 MSIX), the host is **x64-only by design**. It is coupled to
|
Unlike the client (which ships x64 + ARM64 MSIX), the host is **x64-only by design**. It is coupled to
|
||||||
@@ -112,7 +125,6 @@ fresh install uses the generated random console password — read it from
|
|||||||
| `drivers/` | The all-Rust IddCx **driver source** workspace: the `pf-vdisplay` crate on `wdk-sys` / windows-drivers-rs + the owned `pf-driver-proto` ABI + `wdk-iddcx` / `wdk-probe`, plus `deploy-dev.ps1` (build/sign/install for dev). |
|
| `drivers/` | The all-Rust IddCx **driver source** workspace: the `pf-vdisplay` crate on `wdk-sys` / windows-drivers-rs + the owned `pf-driver-proto` ABI + `wdk-iddcx` / `wdk-probe`, plus `deploy-dev.ps1` (build/sign/install for dev). |
|
||||||
| `reset-pf-vdisplay.ps1` | **Dev:** recover a wedged driver — stop host → reap ghost monitor nodes → reload the adapter → start host (no reboot). See *Dev iteration* below. |
|
| `reset-pf-vdisplay.ps1` | **Dev:** recover a wedged driver — stop host → reap ghost monitor nodes → reload the adapter → start host (no reboot). See *Dev iteration* below. |
|
||||||
| `redeploy-pf-vdisplay.ps1` | **Dev:** one-shot redeploy — (optional) build → stop host → `deploy-dev.ps1 -Install` → reload adapter → start host. |
|
| `redeploy-pf-vdisplay.ps1` | **Dev:** one-shot redeploy — (optional) build → stop host → `deploy-dev.ps1 -Install` → reload adapter → start host. |
|
||||||
| `nvenc/nvenc.def`, `nvenc/gen-nvenc-importlib.ps1` | Synthesise `nvencodeapi.lib` for the `--features nvenc` link (llvm-dlltool / lib.exe). |
|
|
||||||
| `pf-vkhdr-layer/` | **HDR Vulkan layer** (standalone `cdylib`): lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the virtual display by advertising the HDR surface formats the NVIDIA/AMD ICDs hide on an indirect display. Built by the packer, laid into `{app}\vklayer`, registered under `HKLM64\…\Khronos\Vulkan\ImplicitLayers` (opt-out *Install the HDR Vulkan layer* task). Self-gated on the display's HDR state. See its README. |
|
| `pf-vkhdr-layer/` | **HDR Vulkan layer** (standalone `cdylib`): lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the virtual display by advertising the HDR surface formats the NVIDIA/AMD ICDs hide on an indirect display. Built by the packer, laid into `{app}\vklayer`, registered under `HKLM64\…\Khronos\Vulkan\ImplicitLayers` (opt-out *Install the HDR Vulkan layer* task). Self-gated on the display's HDR state. See its README. |
|
||||||
|
|
||||||
> **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-dualsense /
|
> **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-dualsense /
|
||||||
@@ -154,14 +166,10 @@ the recovery. From a Linux box drive either over SSH, e.g.
|
|||||||
## Build locally (Windows, MSVC + Windows SDK + Inno Setup)
|
## Build locally (Windows, MSVC + Windows SDK + Inno Setup)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 1. import lib for the nvenc link
|
# 1. build the host (NVENC needs no import lib — its entry points are runtime-loaded)
|
||||||
pwsh -File packaging\windows\nvenc\gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
|
||||||
$env:PUNKTFUNK_NVENC_LIB_DIR = 'C:\t\nvenc'
|
|
||||||
|
|
||||||
# 2. build the host
|
|
||||||
cargo build --release -p punktfunk-host --features nvenc
|
cargo build --release -p punktfunk-host --features nvenc
|
||||||
|
|
||||||
# 3. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip pf-vdisplay)
|
# 2. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip pf-vdisplay)
|
||||||
pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out
|
pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -405,11 +405,21 @@ dependencies = [
|
|||||||
name = "pf-dualsense"
|
name = "pf-dualsense"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"pf-driver-proto",
|
||||||
|
"pf-umdf-util",
|
||||||
"wdk",
|
"wdk",
|
||||||
"wdk-build",
|
"wdk-build",
|
||||||
"wdk-sys",
|
"wdk-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-umdf-util"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"pf-driver-proto",
|
||||||
|
"wdk-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -427,6 +437,8 @@ dependencies = [
|
|||||||
name = "pf-xusb"
|
name = "pf-xusb"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"pf-driver-proto",
|
||||||
|
"pf-umdf-util",
|
||||||
"wdk",
|
"wdk",
|
||||||
"wdk-build",
|
"wdk-build",
|
||||||
"wdk-sys",
|
"wdk-sys",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# crates/pf-driver-proto from the main tree.
|
# crates/pf-driver-proto from the main tree.
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["wdk-probe", "wdk-iddcx", "pf-vdisplay", "pf-dualsense", "pf-xusb"]
|
members = ["wdk-probe", "wdk-iddcx", "pf-umdf-util", "pf-vdisplay", "pf-dualsense", "pf-xusb"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@@ -20,6 +20,7 @@ wdk = "0.4.1"
|
|||||||
wdk-sys = "0.5.1"
|
wdk-sys = "0.5.1"
|
||||||
wdk-build = "0.5.1"
|
wdk-build = "0.5.1"
|
||||||
wdk-iddcx = { path = "wdk-iddcx" }
|
wdk-iddcx = { path = "wdk-iddcx" }
|
||||||
|
pf-umdf-util = { path = "pf-umdf-util" }
|
||||||
pf-driver-proto = { path = "../../../crates/pf-driver-proto" }
|
pf-driver-proto = { path = "../../../crates/pf-driver-proto" }
|
||||||
|
|
||||||
# Vendored windows-drivers-rs 0.5.1 (the published, self-contained crates) + an added `iddcx`
|
# Vendored windows-drivers-rs 0.5.1 (the published, self-contained crates) + an added `iddcx`
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ wdk-build.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
wdk.workspace = true
|
wdk.workspace = true
|
||||||
wdk-sys.workspace = true
|
wdk-sys.workspace = true
|
||||||
|
pf-driver-proto.workspace = true
|
||||||
|
pf-umdf-util.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["hid"]
|
default = ["hid"]
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ silently breaks them:
|
|||||||
|
|
||||||
- **Multi-pad** works via `UmdfHostProcessSharing=ProcessSharingDisabled` — each pad gets its own
|
- **Multi-pad** works via `UmdfHostProcessSharing=ProcessSharingDisabled` — each pad gets its own
|
||||||
WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the
|
WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the
|
||||||
device Location (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>` channel.
|
device Location (`WdfDeviceAllocAndQueryProperty`) to poll its own `*-boot-<index>` bootstrap
|
||||||
|
mailbox (the DATA section itself is unnamed — the sealed pad channel,
|
||||||
|
`design/gamepad-channel-sealing.md` — and its `pad_index` is validated against this index on
|
||||||
|
attach).
|
||||||
- Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature
|
- Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature
|
||||||
blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/dualsense.rs`.
|
blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/dualsense.rs`.
|
||||||
|
|||||||
@@ -1,36 +1,39 @@
|
|||||||
// punktfunk virtual DualSense — UMDF2 HID minidriver (M0 spike).
|
// punktfunk virtual DualSense / DualShock 4 — UMDF2 HID minidriver.
|
||||||
//
|
//
|
||||||
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
|
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
|
||||||
// (VID 054C / PID 0CE6) using the inputtino report descriptor + feature blobs punktfunk already
|
// (VID 054C / PID 0CE6) or DualShock 4 (device_type=1) using the inputtino report descriptor +
|
||||||
// ships in `inject/dualsense.rs`. Its purpose for M0(b) is to (1) enumerate as a genuine DualSense
|
// feature blobs punktfunk already ships in `inject/{dualsense,dualshock4}.rs`. Games see a genuine
|
||||||
// and (2) LOG every output report the game writes — the adaptive-trigger `0x02` gate.
|
// HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back.
|
||||||
//
|
//
|
||||||
// No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics.
|
// No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics.
|
||||||
// All WDF calls go through `call_unsafe_wdf_function_binding!`; HID/WDF structs are hand-built.
|
// The host channel is the **sealed pad channel** (design/gamepad-channel-sealing.md, proto v2): the
|
||||||
|
// whole handshake + all shared-memory access lives in `pf_umdf_util` (the audited unsafe layer), so
|
||||||
|
// this crate's channel/HID/IOCTL logic is 100% SAFE Rust. The only `unsafe` here is the unavoidable
|
||||||
|
// WDF setup FFI in DriverEntry/EvtDeviceAdd/the timer, each with a `// SAFETY:` proof.
|
||||||
|
|
||||||
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
|
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
|
||||||
|
// Every remaining `unsafe {}` (all WDF setup FFI) must carry a `// SAFETY:` proof.
|
||||||
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use core::ffi::c_void;
|
use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering};
|
||||||
use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
|
|
||||||
|
|
||||||
|
use pf_driver_proto::gamepad::PadShm;
|
||||||
|
use pf_umdf_util::channel::{ChannelClient, ChannelConfig};
|
||||||
|
use pf_umdf_util::wdf::{self, Request};
|
||||||
use wdk_sys::{
|
use wdk_sys::{
|
||||||
NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG,
|
NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG,
|
||||||
WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES,
|
WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES,
|
||||||
WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFMEMORY, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER,
|
WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER,
|
||||||
call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
|
call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- NTSTATUS values ----
|
// ---- NTSTATUS values ----
|
||||||
const STATUS_SUCCESS: NTSTATUS = 0;
|
const STATUS_SUCCESS: NTSTATUS = 0;
|
||||||
const STATUS_UNSUCCESSFUL: NTSTATUS = 0xC000_0001u32 as NTSTATUS;
|
|
||||||
const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
|
const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
|
||||||
const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
|
const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
|
||||||
const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS;
|
|
||||||
|
|
||||||
#[inline]
|
use pf_umdf_util::nt_success;
|
||||||
fn nt_success(s: NTSTATUS) -> bool {
|
|
||||||
s >= 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- HID minidriver IOCTLs: CTL_CODE(FILE_DEVICE_KEYBOARD=0x0b, id, METHOD_NEITHER=3, ANY) ----
|
// ---- HID minidriver IOCTLs: CTL_CODE(FILE_DEVICE_KEYBOARD=0x0b, id, METHOD_NEITHER=3, ANY) ----
|
||||||
const fn hid_ctl(id: u32) -> u32 {
|
const fn hid_ctl(id: u32) -> u32 {
|
||||||
@@ -225,26 +228,45 @@ static MANUAL_QUEUE: AtomicPtr<WDFQUEUE__> = AtomicPtr::new(core::ptr::null_mut(
|
|||||||
/// to pended game READ_REPORTs. Defaults to neutral until the host connects.
|
/// to pended game READ_REPORTs. Defaults to neutral until the host connects.
|
||||||
static INPUT_REPORT: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new(NEUTRAL_REPORT);
|
static INPUT_REPORT: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new(NEUTRAL_REPORT);
|
||||||
|
|
||||||
// ---- user-mode shared-memory IPC with the punktfunk host ----
|
// ---- the sealed pad channel: layouts + offsets from pf_driver_proto (drift = compile error) ----
|
||||||
// UMDF runs in WUDFHost.exe (user-mode) and hidclass blocks a control channel on the device stack
|
// UMDF runs in WUDFHost.exe (user-mode) and hidclass blocks a control channel on the device stack
|
||||||
// (custom interface CreateFile → err 31; custom IOCTL on the HID handle → err 1) and UMDF has no
|
// (custom interface CreateFile → err 31; custom IOCTL on the HID handle → err 1) and UMDF has no
|
||||||
// control device, so the host channel is a named section the (privileged) host CREATES and the driver
|
// control device. So the DATA section (`PadShm`, 256 B — input report @8, output seq @72, output
|
||||||
// OPENS. Layout (256 B, must match pf_driver_proto::gamepad::PadShm): magic u32 @0 ("PFDS"),
|
// report @76, device_type @140, health marks @144/@148, pad_index @152) is UNNAMED and reached only
|
||||||
// input_seq u32 @4, input_report[64] @8, output_seq u32 @72, output_report[64] @76,
|
// through a handle the SYSTEM host duplicated into this WUDFHost, bootstrapped over the named mailbox
|
||||||
// device_type u8 @140, driver_proto u32 @144 (we stamp GAMEPAD_PROTO_VERSION = the host's
|
// `Global\pfds-boot-<index>`. The handshake + all shared-memory access live in `pf_umdf_util`.
|
||||||
// driver-attach health signal), driver_heartbeat u32 @148 (we bump per timer tick = liveness).
|
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
|
||||||
const FILE_MAP_RW: u32 = 0x0002 | 0x0004; // FILE_MAP_WRITE | FILE_MAP_READ
|
const SHM_SIZE: usize = core::mem::size_of::<PadShm>();
|
||||||
const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" little-endian
|
const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION;
|
||||||
const SHM_SIZE: usize = 256;
|
|
||||||
const GAMEPAD_PROTO_VERSION: u32 = 1; // must match pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION
|
|
||||||
static LOGGED_SHM: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
|
|
||||||
|
|
||||||
// kernel32 file-mapping APIs (resolved via std's kernel32 import; UMDF permits file mapping).
|
// PadShm field offsets (the driver reads input + device_type, writes output + health marks).
|
||||||
unsafe extern "system" {
|
const OFF_INPUT: usize = core::mem::offset_of!(PadShm, input);
|
||||||
fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void;
|
const OFF_OUT_SEQ: usize = core::mem::offset_of!(PadShm, out_seq);
|
||||||
fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void;
|
const OFF_OUTPUT: usize = core::mem::offset_of!(PadShm, output);
|
||||||
fn UnmapViewOfFile(addr: *const c_void) -> i32;
|
const OFF_DEVICE_TYPE: usize = core::mem::offset_of!(PadShm, device_type);
|
||||||
fn CloseHandle(h: *mut c_void) -> i32;
|
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(PadShm, driver_proto);
|
||||||
|
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(PadShm, driver_heartbeat);
|
||||||
|
const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index);
|
||||||
|
|
||||||
|
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
||||||
|
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
||||||
|
static CHANNEL: ChannelClient = ChannelClient::new();
|
||||||
|
/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4) — the neutral-report shape when
|
||||||
|
/// the channel detaches, and the fallback identity while unattached.
|
||||||
|
static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
|
||||||
|
/// device_type()'s bounded first-read wait fires at most once (see its docs).
|
||||||
|
static DEVTYPE_WAITED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// This pad's channel config (magic/size/pad_index offset + our logger).
|
||||||
|
fn channel_cfg() -> ChannelConfig {
|
||||||
|
ChannelConfig {
|
||||||
|
tag: "pf-ds",
|
||||||
|
boot_name_prefix: "Global\\pfds-boot-",
|
||||||
|
data_magic: SHM_MAGIC,
|
||||||
|
data_size: SHM_SIZE,
|
||||||
|
pad_index_off: OFF_PAD_INDEX,
|
||||||
|
log,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log(s: &str) {
|
fn log(s: &str) {
|
||||||
@@ -289,59 +311,6 @@ pub unsafe extern "system" fn driver_entry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pad index this device serves (which `pfds-shm-<index>` section to map). The host stamps it into
|
|
||||||
/// the device Location (`pszDeviceLocation`); the driver reads it in EvtDeviceAdd. With
|
|
||||||
/// `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) each pad gets its own WUDFHost, so this
|
|
||||||
/// static is per-pad — the basis for multi-pad.
|
|
||||||
static SHM_INDEX: AtomicU32 = AtomicU32::new(0);
|
|
||||||
/// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (not re-exported at the wdk_sys root).
|
|
||||||
const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10;
|
|
||||||
|
|
||||||
/// Read the pad index the host stamped into the device Location (a NUL-terminated UTF-16 decimal
|
|
||||||
/// string). Defaults to 0 (single-pad) if absent.
|
|
||||||
fn query_shm_index(device: WDFDEVICE) -> u32 {
|
|
||||||
let mut mem: WDFMEMORY = core::ptr::null_mut();
|
|
||||||
// SAFETY: device valid; property = LocationInformation; pool ignored in UMDF; mem receives the handle.
|
|
||||||
let st = unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(
|
|
||||||
WdfDeviceAllocAndQueryProperty,
|
|
||||||
device,
|
|
||||||
DEVICE_PROPERTY_LOCATION_INFORMATION,
|
|
||||||
0,
|
|
||||||
WDF_NO_OBJECT_ATTRIBUTES,
|
|
||||||
&mut mem
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if !nt_success(st) || mem.is_null() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let mut len: usize = 0;
|
|
||||||
// SAFETY: mem valid.
|
|
||||||
let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) }
|
|
||||||
as *const u16;
|
|
||||||
if buf.is_null() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let mut idx: u32 = 0;
|
|
||||||
let mut any = false;
|
|
||||||
for i in 0..(len / 2).min(8) {
|
|
||||||
// SAFETY: buf valid for len bytes; i < len/2.
|
|
||||||
let c = unsafe { *buf.add(i) };
|
|
||||||
if c == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (0x30..=0x39).contains(&c) {
|
|
||||||
idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32);
|
|
||||||
any = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if any {
|
|
||||||
idx
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
|
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
|
||||||
log("[pf-ds] EvtDeviceAdd");
|
log("[pf-ds] EvtDeviceAdd");
|
||||||
|
|
||||||
@@ -364,8 +333,9 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
|
|||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
|
|
||||||
let shm_idx = query_shm_index(device);
|
// SAFETY: `device` is the live device just created — the exact contract this fn requires.
|
||||||
SHM_INDEX.store(shm_idx, Ordering::Relaxed);
|
let shm_idx = unsafe { wdf::query_location_index(device) };
|
||||||
|
CHANNEL.set_index(shm_idx);
|
||||||
dbglog!("[pf-ds] shm index = {shm_idx}");
|
dbglog!("[pf-ds] shm index = {shm_idx}");
|
||||||
|
|
||||||
// Default parallel queue handling all IOCTLs.
|
// Default parallel queue handling all IOCTLs.
|
||||||
@@ -428,6 +398,8 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
|
|||||||
tcfg.EvtTimerFunc = Some(evt_timer);
|
tcfg.EvtTimerFunc = Some(evt_timer);
|
||||||
tcfg.Period = 8; // ms
|
tcfg.Period = 8; // ms
|
||||||
tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern)
|
tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern)
|
||||||
|
// SAFETY: a zeroed WDF_OBJECT_ATTRIBUTES is a valid all-null attributes struct; we set Size + the
|
||||||
|
// fields we use below.
|
||||||
let mut tattr: WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
let mut tattr: WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||||
tattr.Size = core::mem::size_of::<WDF_OBJECT_ATTRIBUTES>() as ULONG;
|
tattr.Size = core::mem::size_of::<WDF_OBJECT_ATTRIBUTES>() as ULONG;
|
||||||
tattr.ParentObject = manual_queue.cast();
|
tattr.ParentObject = manual_queue.cast();
|
||||||
@@ -458,141 +430,73 @@ extern "C" fn evt_io_device_control(
|
|||||||
_input_len: usize,
|
_input_len: usize,
|
||||||
ioctl: ULONG,
|
ioctl: ULONG,
|
||||||
) {
|
) {
|
||||||
let mut complete = true;
|
// SAFETY: `request` is the live request for THIS EvtIoDeviceControl invocation — exactly the
|
||||||
|
// contract `Request::new` requires. Everything after is safe (the token owns completion).
|
||||||
|
let request = unsafe { Request::new(request) };
|
||||||
|
|
||||||
// Skip the 8ms READ_REPORT cadence so the log stays readable during a game test;
|
// Skip the 8ms READ_REPORT cadence so the log stays readable during a game test;
|
||||||
// the 0x02 OUTPUT report (the gate) and the descriptor handshake still log.
|
// the 0x02 OUTPUT report (the gate) and the descriptor handshake still log.
|
||||||
if ioctl != IOCTL_HID_READ_REPORT {
|
if ioctl != IOCTL_HID_READ_REPORT {
|
||||||
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}");
|
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}");
|
||||||
}
|
}
|
||||||
let status: NTSTATUS = match ioctl {
|
|
||||||
IOCTL_HID_GET_DEVICE_DESCRIPTOR => {
|
// READ_REPORT forwards to the manual queue (the timer completes it) — this CONSUMES the request
|
||||||
copy_to_output(request, if device_type() == 1 { &DS4_HID_DESC } else { &HID_DESC })
|
// token, so it's handled apart from the status-and-complete paths below.
|
||||||
|
if ioctl == IOCTL_HID_READ_REPORT {
|
||||||
|
let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst);
|
||||||
|
// SAFETY: `mq` is the manual queue created in EvtDeviceAdd (a live WDFQUEUE of this device).
|
||||||
|
match unsafe { request.forward_to_queue(mq) } {
|
||||||
|
Ok(()) => {} // framework owns it now (completed by the timer)
|
||||||
|
Err((req, st)) => req.complete(st), // forward failed → complete with the error
|
||||||
}
|
}
|
||||||
IOCTL_HID_GET_DEVICE_ATTRIBUTES => copy_to_output(request, &hid_attrs(device_type() == 1)),
|
return;
|
||||||
IOCTL_HID_GET_REPORT_DESCRIPTOR => copy_to_output(
|
}
|
||||||
request,
|
|
||||||
if device_type() == 1 {
|
let status: NTSTATUS = match ioctl {
|
||||||
|
IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(if device_type() == 1 {
|
||||||
|
&DS4_HID_DESC
|
||||||
|
} else {
|
||||||
|
&HID_DESC
|
||||||
|
}),
|
||||||
|
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type() == 1)),
|
||||||
|
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(if device_type() == 1 {
|
||||||
&DS4_RDESC[..]
|
&DS4_RDESC[..]
|
||||||
} else {
|
} else {
|
||||||
&DUALSENSE_RDESC[..]
|
&DUALSENSE_RDESC[..]
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
IOCTL_HID_READ_REPORT => {
|
|
||||||
let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst);
|
|
||||||
// SAFETY: request valid; mq is the manual queue created in EvtDeviceAdd.
|
|
||||||
let st = unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, request, mq)
|
|
||||||
};
|
|
||||||
if nt_success(st) {
|
|
||||||
complete = false;
|
|
||||||
STATUS_SUCCESS
|
|
||||||
} else {
|
|
||||||
st
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
|
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
|
||||||
on_output_report(request, ioctl)
|
on_output_report(&request, ioctl)
|
||||||
}
|
}
|
||||||
IOCTL_UMDF_HID_SET_FEATURE => {
|
IOCTL_UMDF_HID_SET_FEATURE => {
|
||||||
log("[pf-ds] SET_FEATURE (stub ok)");
|
log("[pf-ds] SET_FEATURE (stub ok)");
|
||||||
STATUS_SUCCESS
|
STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(request),
|
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
|
||||||
IOCTL_UMDF_HID_GET_INPUT_REPORT => {
|
IOCTL_UMDF_HID_GET_INPUT_REPORT => {
|
||||||
copy_to_output(request, &neutral_report(device_type() == 1))
|
request.copy_to_output(&neutral_report(device_type() == 1))
|
||||||
}
|
}
|
||||||
IOCTL_HID_GET_STRING => on_get_string(request),
|
IOCTL_HID_GET_STRING => on_get_string(&request),
|
||||||
_ => STATUS_NOT_IMPLEMENTED,
|
_ => STATUS_NOT_IMPLEMENTED,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ioctl != IOCTL_HID_READ_REPORT {
|
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x}", status as u32);
|
||||||
dbglog!(
|
request.complete(status);
|
||||||
"[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x} complete={complete}",
|
|
||||||
status as u32
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if complete {
|
|
||||||
// SAFETY: request valid and not forwarded.
|
|
||||||
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy `src` into the request's output memory and set the completed byte count.
|
|
||||||
fn copy_to_output(request: WDFREQUEST, src: &[u8]) -> NTSTATUS {
|
|
||||||
let mut mem: WDFMEMORY = core::ptr::null_mut();
|
|
||||||
// SAFETY: request valid; mem receives the memory handle.
|
|
||||||
let st = unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut mem)
|
|
||||||
};
|
|
||||||
if !nt_success(st) {
|
|
||||||
return st;
|
|
||||||
}
|
|
||||||
let mut outlen: usize = 0;
|
|
||||||
// SAFETY: mem valid; outlen receives the buffer size.
|
|
||||||
let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) };
|
|
||||||
if outlen < src.len() {
|
|
||||||
return STATUS_INVALID_BUFFER_SIZE;
|
|
||||||
}
|
|
||||||
// SAFETY: mem valid; src is a valid buffer of src.len() bytes.
|
|
||||||
let st = unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(
|
|
||||||
WdfMemoryCopyFromBuffer,
|
|
||||||
mem,
|
|
||||||
0usize,
|
|
||||||
src.as_ptr() as *mut c_void,
|
|
||||||
src.len()
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if !nt_success(st) {
|
|
||||||
return st;
|
|
||||||
}
|
|
||||||
// SAFETY: request valid.
|
|
||||||
unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, src.len() as u64)
|
|
||||||
};
|
|
||||||
STATUS_SUCCESS
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The 0x02 gate: a game writing an output report (rumble / lightbar / ADAPTIVE TRIGGERS). Per the
|
// The 0x02 gate: a game writing an output report (rumble / lightbar / ADAPTIVE TRIGGERS). Per the
|
||||||
// UMDF marshalling convention the report data is the *input* buffer and the report id is carried in
|
// UMDF marshalling convention the report data is the *input* buffer and the report id is carried in
|
||||||
// the *output* buffer length. We log it.
|
// the *output* buffer length. We log it, then publish it to the DATA section for the host.
|
||||||
fn on_output_report(request: WDFREQUEST, ioctl: ULONG) -> NTSTATUS {
|
fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
|
||||||
let mut inmem: WDFMEMORY = core::ptr::null_mut();
|
let (bytes, inlen) = match request.input_bytes(64) {
|
||||||
// SAFETY: request valid.
|
Ok(v) => v,
|
||||||
let st = unsafe {
|
Err(st) => return st,
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
|
|
||||||
};
|
};
|
||||||
if !nt_success(st) {
|
let report_id = request.output_buffer_len() as u32; // report id, UMDF convention
|
||||||
return st;
|
|
||||||
}
|
|
||||||
let mut inlen: usize = 0;
|
|
||||||
// SAFETY: inmem valid.
|
|
||||||
let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) }
|
|
||||||
as *const u8;
|
|
||||||
|
|
||||||
// report id from output-buffer length (UMDF convention).
|
|
||||||
let mut report_id: u32 = 0;
|
|
||||||
let mut outmem: WDFMEMORY = core::ptr::null_mut();
|
|
||||||
// SAFETY: request valid; output memory is optional here.
|
|
||||||
if nt_success(unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut outmem)
|
|
||||||
}) {
|
|
||||||
let mut outlen: usize = 0;
|
|
||||||
// SAFETY: outmem valid.
|
|
||||||
let _ =
|
|
||||||
unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, outmem, &mut outlen) };
|
|
||||||
report_id = outlen as u32;
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = inlen.min(48);
|
|
||||||
let mut hex = String::new();
|
let mut hex = String::new();
|
||||||
if !inbuf.is_null() {
|
for b in bytes.iter().take(48) {
|
||||||
// SAFETY: inbuf valid for inlen bytes; we read at most n.
|
|
||||||
let bytes = unsafe { core::slice::from_raw_parts(inbuf, n) };
|
|
||||||
for b in bytes {
|
|
||||||
hex.push_str(&format!("{b:02x} "));
|
hex.push_str(&format!("{b:02x} "));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let kind = if ioctl == IOCTL_HID_WRITE_REPORT {
|
let kind = if ioctl == IOCTL_HID_WRITE_REPORT {
|
||||||
"WRITE_REPORT"
|
"WRITE_REPORT"
|
||||||
} else {
|
} else {
|
||||||
@@ -600,45 +504,29 @@ fn on_output_report(request: WDFREQUEST, ioctl: ULONG) -> NTSTATUS {
|
|||||||
};
|
};
|
||||||
dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}");
|
dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}");
|
||||||
|
|
||||||
// Publish the game's 0x02 output report to shared memory for the host (rumble / lightbar /
|
// Publish the game's 0x02 output report to the sealed DATA section for the host (rumble /
|
||||||
// player-LEDs / adaptive triggers). output_report @76, output_seq @72.
|
// lightbar / player-LEDs / adaptive triggers), then bump the host-polled output seq.
|
||||||
if !inbuf.is_null() && inlen > 0 {
|
if !bytes.is_empty()
|
||||||
let n = inlen.min(64);
|
&& let Some(view) = CHANNEL.data()
|
||||||
with_shm(|view| {
|
{
|
||||||
// SAFETY: view is a mapped 256-byte section; write the report then bump the host-polled seq.
|
view.write_bytes(OFF_OUTPUT, &bytes);
|
||||||
unsafe {
|
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
||||||
core::ptr::copy_nonoverlapping(inbuf, view.add(76), n);
|
view.write_u32(OFF_OUT_SEQ, seq);
|
||||||
let seqp = view.add(72) as *mut u32;
|
|
||||||
let seq = core::ptr::read_unaligned(seqp).wrapping_add(1);
|
|
||||||
core::ptr::write_unaligned(seqp, seq);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: request valid.
|
request.set_information(inlen as u64);
|
||||||
unsafe { call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, inlen as u64) };
|
|
||||||
STATUS_SUCCESS
|
STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense feature blob.
|
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob.
|
||||||
fn on_get_feature(request: WDFREQUEST) -> NTSTATUS {
|
fn on_get_feature(request: &Request) -> NTSTATUS {
|
||||||
let mut inmem: WDFMEMORY = core::ptr::null_mut();
|
let (bytes, _) = match request.input_bytes(1) {
|
||||||
// SAFETY: request valid.
|
Ok(v) => v,
|
||||||
let st = unsafe {
|
Err(st) => return st,
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
|
|
||||||
};
|
};
|
||||||
if !nt_success(st) {
|
let Some(&report_id) = bytes.first() else {
|
||||||
return st;
|
|
||||||
}
|
|
||||||
let mut inlen: usize = 0;
|
|
||||||
// SAFETY: inmem valid.
|
|
||||||
let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) }
|
|
||||||
as *const u8;
|
|
||||||
if inbuf.is_null() || inlen < 1 {
|
|
||||||
return STATUS_INVALID_PARAMETER;
|
return STATUS_INVALID_PARAMETER;
|
||||||
}
|
};
|
||||||
// SAFETY: inbuf valid for >=1 byte.
|
|
||||||
let report_id = unsafe { *inbuf };
|
|
||||||
// DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3.
|
// DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3.
|
||||||
let blob: &[u8] = match (device_type() == 1, report_id) {
|
let blob: &[u8] = match (device_type() == 1, report_id) {
|
||||||
(false, 0x05) => &DS_FEATURE_CALIBRATION,
|
(false, 0x05) => &DS_FEATURE_CALIBRATION,
|
||||||
@@ -652,31 +540,21 @@ fn on_get_feature(request: WDFREQUEST) -> NTSTATUS {
|
|||||||
return STATUS_INVALID_PARAMETER;
|
return STATUS_INVALID_PARAMETER;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
copy_to_output(request, blob)
|
request.copy_to_output(blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word is
|
// IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word is
|
||||||
// the language id. Reply with the requested device string as a NUL-terminated UTF-16 buffer. Native
|
// the language id. Reply with the requested device string as a NUL-terminated UTF-16 buffer. Native
|
||||||
// PS5 / Steam code reads these (HidD_GetProductString / HidD_GetSerialNumberString — the serial is one
|
// PS5 / Steam code reads these (HidD_GetProductString / HidD_GetSerialNumberString — the serial is one
|
||||||
// way they tell USB from BT); the old default returned STATUS_NOT_IMPLEMENTED, leaving them blank.
|
// way they tell USB from BT). Observed live: Windows polls ids 0x0E/0x0F/0x10 (lang 0x0409)
|
||||||
// Observed live on this device, Windows polls ids 0x0E/0x0F/0x10 (lang 0x0409) cyclically — the
|
// cyclically — the manufacturer/product/serial slots — NOT the 0/1/2 HID_STRING_ID_* constants; both.
|
||||||
// manufacturer/product/serial slots — NOT the 0/1/2 HID_STRING_ID_* constants; we map both forms.
|
fn on_get_string(request: &Request) -> NTSTATUS {
|
||||||
fn on_get_string(request: WDFREQUEST) -> NTSTATUS {
|
let (bytes, _) = match request.input_bytes(4) {
|
||||||
let mut inmem: WDFMEMORY = core::ptr::null_mut();
|
Ok(v) => v,
|
||||||
// SAFETY: request valid.
|
Err(st) => return st,
|
||||||
let st = unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
|
|
||||||
};
|
};
|
||||||
if !nt_success(st) {
|
let id_val: u32 = if bytes.len() >= 4 {
|
||||||
return st;
|
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||||
}
|
|
||||||
let mut inlen: usize = 0;
|
|
||||||
// SAFETY: inmem valid.
|
|
||||||
let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) }
|
|
||||||
as *const u8;
|
|
||||||
// SAFETY: inbuf is valid for inlen bytes; read the 4-byte id value when present.
|
|
||||||
let id_val: u32 = if !inbuf.is_null() && inlen >= 4 {
|
|
||||||
unsafe { core::ptr::read_unaligned(inbuf as *const u32) }
|
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -706,96 +584,81 @@ fn on_get_string(request: WDFREQUEST) -> NTSTATUS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut wide: Vec<u16> = s.encode_utf16().collect();
|
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
||||||
wide.push(0); // NUL terminator
|
for u in s.encode_utf16() {
|
||||||
// SAFETY: reinterpret the UTF-16 buffer as bytes for the byte-oriented copy_to_output.
|
wide.extend_from_slice(&u.to_le_bytes());
|
||||||
let bytes = unsafe { core::slice::from_raw_parts(wide.as_ptr() as *const u8, wide.len() * 2) };
|
}
|
||||||
copy_to_output(request, bytes)
|
wide.extend_from_slice(&[0, 0]); // NUL terminator (UTF-16)
|
||||||
|
request.copy_to_output(&wide)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open + map the host's shared-memory section (Global\pfds-shm-0) and run `f` against the mapped base
|
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
|
||||||
// if it exists with a valid magic, then unmap. NOT cached: re-mapped per access so the driver always
|
/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap. If the channel hasn't
|
||||||
// sees the current section (UMDF groups all devices in one WUDFHost, and the host may recreate the
|
/// attached when hidclass first asks (the host stamps the section + eager-delivers before
|
||||||
// section across restarts — a cached view would go stale). ~125 maps/s from the timer = negligible.
|
/// `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel briefly —
|
||||||
fn with_shm<F: FnOnce(*mut u8)>(f: F) {
|
/// ONCE — for the delivery: a DS4 pad must not enumerate with the default DualSense identity because
|
||||||
let name: Vec<u16> = format!("Global\\pfds-shm-{}", SHM_INDEX.load(Ordering::Relaxed))
|
/// of a lost race. After that one bounded wait, fall back to the last observed type.
|
||||||
.encode_utf16()
|
fn device_type() -> u8 {
|
||||||
.chain(std::iter::once(0))
|
if let Some(view) = CHANNEL.data() {
|
||||||
.collect();
|
let t = view.read_u8(OFF_DEVICE_TYPE);
|
||||||
// SAFETY: name is a valid NUL-terminated UTF-16 string.
|
LAST_DEVTYPE.store(t as u32, Ordering::Relaxed);
|
||||||
let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, name.as_ptr()) };
|
return t;
|
||||||
if h.is_null() {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// SAFETY: h is a valid mapping handle; map the whole section. The view keeps the section alive,
|
if !DEVTYPE_WAITED.swap(true, Ordering::SeqCst) {
|
||||||
// so the handle can be closed right away.
|
let cfg = channel_cfg();
|
||||||
let view = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, SHM_SIZE) } as *mut u8;
|
for _ in 0..100 {
|
||||||
unsafe { CloseHandle(h) };
|
if let Some(view) = CHANNEL.pump(&cfg) {
|
||||||
if view.is_null() {
|
let t = view.read_u8(OFF_DEVICE_TYPE);
|
||||||
return;
|
LAST_DEVTYPE.store(t as u32, Ordering::Relaxed);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
// SAFETY: view points at >= 4 mapped bytes.
|
|
||||||
let magic = unsafe { core::ptr::read_unaligned(view as *const u32) };
|
|
||||||
if magic == SHM_MAGIC {
|
|
||||||
if !LOGGED_SHM.swap(true, Ordering::Relaxed) {
|
|
||||||
dbglog!(
|
dbglog!(
|
||||||
"[pf-ds] control: shared memory mapped (Global\\pfds-shm-{})",
|
"[pf-ds] device_type: sealed channel not attached within 1s — defaulting to the last observed identity"
|
||||||
SHM_INDEX.load(Ordering::Relaxed)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
f(view);
|
LAST_DEVTYPE.load(Ordering::Relaxed) as u8
|
||||||
}
|
|
||||||
// SAFETY: view came from MapViewOfFile.
|
|
||||||
unsafe { UnmapViewOfFile(view as *const c_void) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The host's device-type selector from shared memory (`device_type` byte @140): 0 = DualSense
|
|
||||||
/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap, and the host stamps the
|
|
||||||
/// section before `SwDeviceCreate`, so it's set by the time hidclass asks for the descriptor /
|
|
||||||
/// attributes. Defaults to DualSense if the section isn't mapped yet (magic absent).
|
|
||||||
fn device_type() -> u8 {
|
|
||||||
let mut t = 0u8;
|
|
||||||
with_shm(|view| {
|
|
||||||
// SAFETY: view points at a mapped 256-byte section; the device-type byte is at offset 140.
|
|
||||||
t = unsafe { *view.add(140) };
|
|
||||||
});
|
|
||||||
t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" fn evt_timer(timer: WDFTIMER) {
|
extern "C" fn evt_timer(timer: WDFTIMER) {
|
||||||
// Pull the latest host input report from shared memory (if the host has connected).
|
// One sealed-channel tick: publish our pid / adopt a delivery / detect host-gone, then pull the
|
||||||
with_shm(|view| {
|
// latest host input report from the attached DATA section (all safe, via pf_umdf_util).
|
||||||
|
match CHANNEL.pump(&channel_cfg()) {
|
||||||
|
Some(view) => {
|
||||||
let mut buf = [0u8; 64];
|
let mut buf = [0u8; 64];
|
||||||
// SAFETY: view points at a mapped 256-byte section; input lives at offset 8..72.
|
view.read_bytes(OFF_INPUT, &mut buf);
|
||||||
unsafe { core::ptr::copy_nonoverlapping(view.add(8), buf.as_mut_ptr(), 64) };
|
if buf[0] == 0x01
|
||||||
if buf[0] == 0x01 {
|
&& let Ok(mut g) = INPUT_REPORT.lock()
|
||||||
if let Ok(mut g) = INPUT_REPORT.lock() {
|
{
|
||||||
*g = buf;
|
*g = buf;
|
||||||
}
|
}
|
||||||
}
|
// Health marks the host watches: driver_proto (attach signal, idempotent) and
|
||||||
// Health marks the host watches: driver_proto @144 (attach signal, idempotent) and
|
// driver_heartbeat (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound
|
||||||
// driver_heartbeat @148 (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound
|
|
||||||
// and alive" apart from "driver package missing/failed to bind".
|
// and alive" apart from "driver package missing/failed to bind".
|
||||||
// SAFETY: view points at a mapped 256-byte section; proto @144, heartbeat @148.
|
view.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
|
||||||
unsafe {
|
let hb = view.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
|
||||||
core::ptr::write_unaligned(view.add(144) as *mut u32, GAMEPAD_PROTO_VERSION);
|
view.write_u32(OFF_DRIVER_HEARTBEAT, hb);
|
||||||
let hb = view.add(148) as *mut u32;
|
|
||||||
core::ptr::write_unaligned(hb, core::ptr::read_unaligned(hb).wrapping_add(1));
|
|
||||||
}
|
}
|
||||||
});
|
None => {
|
||||||
// SAFETY: timer valid; parent is the manual queue.
|
// Host gone (mailbox name vanished) or channel not attached yet: feed games the neutral
|
||||||
|
// report instead of a frozen last state (matters for the persistent out-of-band devnode,
|
||||||
|
// which outlives host sessions).
|
||||||
|
if let Ok(mut g) = INPUT_REPORT.lock() {
|
||||||
|
*g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) == 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete the next pended READ_REPORT with the current input report (safe queue/request API).
|
||||||
|
// SAFETY: the timer's parent object is the manual queue (set in EvtDeviceAdd); the framework
|
||||||
|
// guarantees a live handle here.
|
||||||
let queue =
|
let queue =
|
||||||
unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE;
|
unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE;
|
||||||
let mut request: WDFREQUEST = core::ptr::null_mut();
|
// SAFETY: `queue` is that live manual queue — the exact contract `retrieve_next_request` needs.
|
||||||
// SAFETY: queue valid; request receives the next pended request if any.
|
if let Some(request) = unsafe { wdf::retrieve_next_request(queue) } {
|
||||||
let st = unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(WdfIoQueueRetrieveNextRequest, queue, &mut request)
|
|
||||||
};
|
|
||||||
if nt_success(st) {
|
|
||||||
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
|
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
|
||||||
let s = copy_to_output(request, &report);
|
let st = request.copy_to_output(&report);
|
||||||
// SAFETY: request valid and dequeued.
|
request.complete(st);
|
||||||
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, s) };
|
|
||||||
}
|
}
|
||||||
let _ = STATUS_UNSUCCESSFUL; // keep the const referenced
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# pf-umdf-util - the audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers.
|
||||||
|
# Everything a pad driver does with raw pointers or Win32/WDF FFI lives HERE, behind small safe
|
||||||
|
# (or explicitly-contracted unsafe) APIs, so the driver crates' business logic is 100% safe Rust:
|
||||||
|
# section - MappedView: bounds+alignment-checked shared-memory access (atomics for sync fields)
|
||||||
|
# channel - ChannelClient: the sealed pad channel's driver-side state machine (a SAFE module)
|
||||||
|
# wdf - Request/queue/device-property helpers over call_unsafe_wdf_function_binding
|
||||||
|
[package]
|
||||||
|
name = "pf-umdf-util"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish = false
|
||||||
|
description = "punktfunk UMDF driver util: safe shared-memory + sealed-channel + WDF request primitives"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wdk-sys.workspace = true
|
||||||
|
pf-driver-proto.workspace = true
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! The sealed pad channel, driver side (`design/gamepad-channel-sealing.md`, gamepad proto v2):
|
||||||
|
//! poll the named bootstrap mailbox by index, publish our pid (iff the host's proto version
|
||||||
|
//! matches), adopt the host-delivered DATA-section handle, and validate the mapped section's magic
|
||||||
|
//! and `pad_index` before use. One implementation shared by `pf-xusb` and `pf-dualsense` (they used
|
||||||
|
//! to hand-duplicate it), parameterized by [`ChannelConfig`].
|
||||||
|
//!
|
||||||
|
//! This module **forbids `unsafe`**: the entire state machine is safe Rust over
|
||||||
|
//! [`section`](crate::section)'s checked accessors — the memory-safety surface of the sealed
|
||||||
|
//! channel lives in that module alone.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use crate::section::{MappedView, ViewCell, close_handle_value};
|
||||||
|
use core::mem::offset_of;
|
||||||
|
use core::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use pf_driver_proto::gamepad::{BOOT_MAGIC, GAMEPAD_PROTO_VERSION, PadBootstrap};
|
||||||
|
|
||||||
|
// PadBootstrap field offsets (the mailbox handshake; pinned by pf_driver_proto's asserts).
|
||||||
|
const BOOT_OFF_MAGIC: usize = offset_of!(PadBootstrap, magic);
|
||||||
|
const BOOT_OFF_HOST_PROTO: usize = offset_of!(PadBootstrap, host_proto);
|
||||||
|
const BOOT_OFF_DRIVER_PID: usize = offset_of!(PadBootstrap, driver_pid);
|
||||||
|
const BOOT_OFF_DRIVER_PROTO: usize = offset_of!(PadBootstrap, driver_proto);
|
||||||
|
const BOOT_OFF_DATA_HANDLE: usize = offset_of!(PadBootstrap, data_handle);
|
||||||
|
const BOOT_OFF_HANDLE_PID: usize = offset_of!(PadBootstrap, handle_pid);
|
||||||
|
const BOOT_OFF_HANDLE_SEQ: usize = offset_of!(PadBootstrap, handle_seq);
|
||||||
|
const BOOT_SIZE: usize = core::mem::size_of::<PadBootstrap>();
|
||||||
|
|
||||||
|
/// What varies between the two pad drivers.
|
||||||
|
pub struct ChannelConfig {
|
||||||
|
/// Log-line prefix (`"pf-xusb"` / `"pf-ds"`).
|
||||||
|
pub tag: &'static str,
|
||||||
|
/// Mailbox name prefix, completed with the pad index (`"Global\\pfxusb-boot-"` / `"Global\\pfds-boot-"`).
|
||||||
|
pub boot_name_prefix: &'static str,
|
||||||
|
/// The DATA section's magic (`XUSB_MAGIC` / `PAD_MAGIC`).
|
||||||
|
pub data_magic: u32,
|
||||||
|
/// The DATA section's size (`size_of::<XusbShm>()` / `size_of::<PadShm>()`).
|
||||||
|
pub data_size: usize,
|
||||||
|
/// `offset_of!(…Shm, pad_index)` in the DATA section.
|
||||||
|
pub pad_index_off: usize,
|
||||||
|
/// The driver's logger (each driver tees to its own debug file).
|
||||||
|
pub log: fn(&str),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-pad channel state (a `static` in each driver — per-pad because
|
||||||
|
/// `UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own WUDFHost).
|
||||||
|
pub struct ChannelClient {
|
||||||
|
/// The pad index from the devnode Location (which mailbox to poll + the `pad_index` the
|
||||||
|
/// delivered DATA section must carry).
|
||||||
|
index: AtomicU32,
|
||||||
|
/// The adopted DATA view; leaked-on-publish (see [`ViewCell`]) so a re-delivery can never
|
||||||
|
/// unmap a view a concurrent callback still reads through.
|
||||||
|
data: ViewCell,
|
||||||
|
/// The last `handle_seq` consumed (CAS-guarded so concurrent pumps adopt a delivery exactly
|
||||||
|
/// once). Reset to 0 when the mailbox disappears, so a NEW host session's delivery is always
|
||||||
|
/// fresh even if its (per-host-process) seq counter collides with the previous session's.
|
||||||
|
consumed_seq: AtomicU32,
|
||||||
|
logged_proto_mismatch: AtomicBool,
|
||||||
|
logged_pid: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ChannelClient {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelClient {
|
||||||
|
pub const fn new() -> ChannelClient {
|
||||||
|
ChannelClient {
|
||||||
|
index: AtomicU32::new(0),
|
||||||
|
data: ViewCell::new(),
|
||||||
|
consumed_seq: AtomicU32::new(0),
|
||||||
|
logged_proto_mismatch: AtomicBool::new(false),
|
||||||
|
logged_pid: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the pad index (from the devnode Location, in `EvtDeviceAdd`).
|
||||||
|
pub fn set_index(&self, idx: u32) {
|
||||||
|
self.index.store(idx, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn index(&self) -> u32 {
|
||||||
|
self.index.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The adopted DATA view regardless of mailbox liveness — for write paths where acting on a
|
||||||
|
/// stale section is harmless (the pump owns the detach semantics).
|
||||||
|
pub fn data(&self) -> Option<&'static MappedView> {
|
||||||
|
self.data.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tick of the sealed-channel state machine: publish our pid (+ proto version) in the
|
||||||
|
/// mailbox, adopt a delivered DATA handle, and return the attached DATA view — `None` while
|
||||||
|
/// unattached, on a host/driver version mismatch (fail closed), or when the mailbox is gone
|
||||||
|
/// (host gone). The mailbox is re-opened by name on every call: the name existing doubles as
|
||||||
|
/// host-liveness (the host closes it when the pad is torn down).
|
||||||
|
pub fn pump(&self, cfg: &ChannelConfig) -> Option<&'static MappedView> {
|
||||||
|
let name = format!("{}{}", cfg.boot_name_prefix, self.index());
|
||||||
|
let boot = match MappedView::open_named(&name, BOOT_SIZE) {
|
||||||
|
Some(b) => b,
|
||||||
|
None => {
|
||||||
|
// Mailbox gone → the host (or this pad) is gone. Forget the consumed seq so the
|
||||||
|
// NEXT host session's first delivery always reads as fresh.
|
||||||
|
self.consumed_seq.store(0, Ordering::Relaxed);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Acquire pairs with the host's Release magic store, so a valid magic implies `host_proto`
|
||||||
|
// is visible. A missing/garbled magic reads as "no usable mailbox" (same as absent).
|
||||||
|
if boot.load_u32(BOOT_OFF_MAGIC, Ordering::Acquire) != BOOT_MAGIC {
|
||||||
|
self.consumed_seq.store(0, Ordering::Relaxed);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Publish our proto version first (idempotent) — the host logs a mismatch even when we
|
||||||
|
// refuse to publish a pid below.
|
||||||
|
boot.store_u32(
|
||||||
|
BOOT_OFF_DRIVER_PROTO,
|
||||||
|
GAMEPAD_PROTO_VERSION,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
let host_proto = boot.load_u32(BOOT_OFF_HOST_PROTO, Ordering::Relaxed);
|
||||||
|
if host_proto != GAMEPAD_PROTO_VERSION {
|
||||||
|
if !self.logged_proto_mismatch.swap(true, Ordering::Relaxed) {
|
||||||
|
(cfg.log)(&format!(
|
||||||
|
"[{}] host proto {host_proto} != driver proto {GAMEPAD_PROTO_VERSION} — \
|
||||||
|
refusing the handshake (update host + drivers together)",
|
||||||
|
cfg.tag
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return None; // version mismatch — fail closed
|
||||||
|
}
|
||||||
|
let mypid = std::process::id();
|
||||||
|
if boot.load_u32(BOOT_OFF_DRIVER_PID, Ordering::Relaxed) != mypid {
|
||||||
|
boot.store_u32(BOOT_OFF_DRIVER_PID, mypid, Ordering::Release);
|
||||||
|
if !self.logged_pid.swap(true, Ordering::Relaxed) {
|
||||||
|
(cfg.log)(&format!("[{}] bootstrap: published pid {mypid}", cfg.tag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A delivery addressed to us we haven't consumed? CAS so concurrent pumps (worker thread /
|
||||||
|
// timer + IOCTL paths) adopt exactly once.
|
||||||
|
let seq = boot.load_u32(BOOT_OFF_HANDLE_SEQ, Ordering::Acquire);
|
||||||
|
let cur = self.consumed_seq.load(Ordering::Relaxed);
|
||||||
|
if seq != 0
|
||||||
|
&& seq != cur
|
||||||
|
&& boot.load_u32(BOOT_OFF_HANDLE_PID, Ordering::Relaxed) == mypid
|
||||||
|
&& self
|
||||||
|
.consumed_seq
|
||||||
|
.compare_exchange(cur, seq, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
self.adopt(cfg, boot.load_u64(BOOT_OFF_DATA_HANDLE, Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
self.data()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map + validate a delivered DATA-section handle VALUE (untrusted until the mapped section
|
||||||
|
/// carries our magic AND our pad index). On success we own the handle (adopt-on-success) and
|
||||||
|
/// close it — the view keeps the section alive. On validation failure the handle is
|
||||||
|
/// deliberately NOT closed: a tampered value could name an unrelated handle in our own table.
|
||||||
|
fn adopt(&self, cfg: &ChannelConfig, value: u64) {
|
||||||
|
let Some(view) = MappedView::from_handle_value(value, cfg.data_size) else {
|
||||||
|
if value != 0 {
|
||||||
|
(cfg.log)(&format!(
|
||||||
|
"[{}] delivered DATA handle 0x{value:x} did not map — ignoring",
|
||||||
|
cfg.tag
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let magic = view.load_u32(0, Ordering::Relaxed);
|
||||||
|
let idx = view.load_u32(cfg.pad_index_off, Ordering::Relaxed);
|
||||||
|
let want = self.index();
|
||||||
|
if magic != cfg.data_magic || idx != want {
|
||||||
|
(cfg.log)(&format!(
|
||||||
|
"[{}] delivered DATA section failed validation (magic 0x{magic:08x}, pad_index \
|
||||||
|
{idx}, want {want}) — ignoring",
|
||||||
|
cfg.tag
|
||||||
|
));
|
||||||
|
// `view` drops here → unmapped; the handle stays open (see above).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The value resolved to OUR pad's section, so it is the handle the host duplicated for us —
|
||||||
|
// we own it; the (about-to-be-leaked) view keeps the section alive after the close.
|
||||||
|
close_handle_value(value);
|
||||||
|
self.data.set(view);
|
||||||
|
(cfg.log)(&format!(
|
||||||
|
"[{}] sealed pad channel mapped (index {want})",
|
||||||
|
cfg.tag
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//! The audited unsafe-primitive layer under the punktfunk UMDF gamepad drivers (`pf-xusb`,
|
||||||
|
//! `pf-dualsense`).
|
||||||
|
//!
|
||||||
|
//! A UMDF driver cannot be literally free of `unsafe` — WDF dispatch, Win32 section mapping and
|
||||||
|
//! cross-process shared memory are FFI by nature. What Rust *can* buy is confining every raw
|
||||||
|
//! operation to one small, reviewed layer with explicit contracts, so the drivers' business logic
|
||||||
|
//! (the sealed-channel state machine, report plumbing, IOCTL policy) is **100 % safe code** and a
|
||||||
|
//! memory-safety bug can only live in this crate. Three modules:
|
||||||
|
//!
|
||||||
|
//! * [`section`] — [`section::MappedView`]: bounds- and alignment-checked access to a mapped shared
|
||||||
|
//! section (atomics for the cross-process sync fields), plus the leaked-view [`section::ViewCell`].
|
||||||
|
//! * [`channel`] — [`channel::ChannelClient`]: the sealed pad channel's driver side
|
||||||
|
//! (`design/gamepad-channel-sealing.md`), a **`#[forbid(unsafe_code)]` module** — the entire
|
||||||
|
//! handshake/validation/adoption state machine is safe Rust over [`section`]'s API.
|
||||||
|
//! * [`wdf`] — [`wdf::Request`] + queue/device-property helpers: each framework callback converts
|
||||||
|
//! its raw `WDFREQUEST` into a token exactly once (`unsafe`, with the framework's validity as the
|
||||||
|
//! contract); everything after that is safe.
|
||||||
|
//!
|
||||||
|
//! Lint gates (mirrored in every driver crate, enforced by the drivers CI clippy step):
|
||||||
|
//! `unsafe_op_in_unsafe_fn` + `clippy::undocumented_unsafe_blocks` — every remaining `unsafe {}`
|
||||||
|
//! must carry a `// SAFETY:` proof.
|
||||||
|
|
||||||
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
pub mod channel;
|
||||||
|
pub mod section;
|
||||||
|
pub mod wdf;
|
||||||
|
|
||||||
|
/// `NT_SUCCESS` — an NTSTATUS is an error iff negative.
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub const fn nt_success(status: wdk_sys::NTSTATUS) -> bool {
|
||||||
|
status >= 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! Safe access to Win32 shared-memory sections: [`MappedView`] wraps a mapped view of a known
|
||||||
|
//! length and exposes bounds- and alignment-checked accessors, so callers never touch the raw base
|
||||||
|
//! pointer. Cross-process sync fields (seqs, pids, handle values) go through real atomics; bulk
|
||||||
|
//! report regions use plain unaligned copies, guarded by the channel protocol's seq fields — the
|
||||||
|
//! same access discipline the host side uses (`inject/windows/gamepad_raii.rs`).
|
||||||
|
|
||||||
|
use core::ffi::c_void;
|
||||||
|
use core::sync::atomic::{AtomicPtr, AtomicU32, AtomicU64, Ordering};
|
||||||
|
|
||||||
|
const FILE_MAP_RW: u32 = 0x0002 | 0x0004; // FILE_MAP_WRITE | FILE_MAP_READ
|
||||||
|
|
||||||
|
// kernel32 file-mapping APIs (resolved via std's kernel32 import; UMDF permits file mapping).
|
||||||
|
unsafe extern "system" {
|
||||||
|
fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void;
|
||||||
|
fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void;
|
||||||
|
fn UnmapViewOfFile(addr: *const c_void) -> i32;
|
||||||
|
fn CloseHandle(h: *mut c_void) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read/write view over a mapped shared section of exactly `len` bytes. Every accessor
|
||||||
|
/// bounds-checks (and, for the atomic ones, alignment-checks) its offset, so no caller can read or
|
||||||
|
/// write outside the mapping — the offsets are `offset_of!` constants from `pf_driver_proto`, making
|
||||||
|
/// a failed check a compile-shaped logic bug (it aborts the WUDFHost rather than corrupting).
|
||||||
|
///
|
||||||
|
/// Concurrency: the peer process writes the section concurrently. Fields used for cross-process
|
||||||
|
/// synchronization must be accessed through the `load_*`/`store_*` atomic accessors; the bulk
|
||||||
|
/// byte/scalar accessors are plain unaligned accesses whose consistency is guarded by the channel
|
||||||
|
/// protocol (seq-fenced publishes), exactly as on the host side.
|
||||||
|
pub struct MappedView {
|
||||||
|
base: *mut u8,
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: `MappedView` is a pointer + length over an OS mapping that stays valid until
|
||||||
|
// `UnmapViewOfFile` in `Drop` (or forever, once leaked into a `ViewCell`). All access goes through
|
||||||
|
// the checked accessors — atomics for shared sync fields, unaligned reads/writes for bulk data —
|
||||||
|
// none of which require a single-thread owner, so sharing/sending the view across the driver's
|
||||||
|
// callback threads is sound.
|
||||||
|
unsafe impl Send for MappedView {}
|
||||||
|
// SAFETY: as above — `&MappedView` only exposes accessors that are safe under concurrent use.
|
||||||
|
unsafe impl Sync for MappedView {}
|
||||||
|
|
||||||
|
impl MappedView {
|
||||||
|
/// Open the named section `name` and map its first `len` bytes read/write. `None` if the name
|
||||||
|
/// does not exist (e.g. the host is gone) or the mapping fails. The section handle is closed
|
||||||
|
/// immediately — the view keeps the section alive.
|
||||||
|
pub fn open_named(name: &str, len: usize) -> Option<MappedView> {
|
||||||
|
let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
// SAFETY: `wide` is a valid NUL-terminated UTF-16 string for the duration of the call.
|
||||||
|
let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, wide.as_ptr()) };
|
||||||
|
if h.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// SAFETY: `h` is the valid mapping handle just opened; map `len` bytes read/write. The view
|
||||||
|
// keeps the section alive, so the handle can be closed right away.
|
||||||
|
let base = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, len) } as *mut u8;
|
||||||
|
// SAFETY: `h` is the valid handle from `OpenFileMappingW`, owned solely by this function.
|
||||||
|
unsafe { CloseHandle(h) };
|
||||||
|
if base.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(MappedView { base, len })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map `len` bytes of a section from a raw handle VALUE (the sealed channel's delivery — a
|
||||||
|
/// handle the host duplicated into this process). `None` if the value does not resolve to a
|
||||||
|
/// mappable section. The handle itself is NOT consumed — the caller decides after validating
|
||||||
|
/// the mapped content (see [`close_handle_value`]).
|
||||||
|
pub fn from_handle_value(value: u64, len: usize) -> Option<MappedView> {
|
||||||
|
if value == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// SAFETY: `MapViewOfFile` on an arbitrary handle value is safe — it fails (returns null)
|
||||||
|
// unless the value resolves to a section handle in this process's table with RW access.
|
||||||
|
let base = unsafe { MapViewOfFile(value as usize as *mut c_void, FILE_MAP_RW, 0, 0, len) }
|
||||||
|
as *mut u8;
|
||||||
|
if base.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(MappedView { base, len })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert `off..off+n` is inside the view and, for atomics, `align`-aligned. The view base is
|
||||||
|
/// page-aligned (`MapViewOfFile`), so field alignment reduces to offset alignment.
|
||||||
|
#[inline]
|
||||||
|
fn check(&self, off: usize, n: usize, align: usize) {
|
||||||
|
assert!(
|
||||||
|
off.is_multiple_of(align) && off.checked_add(n).is_some_and(|end| end <= self.len),
|
||||||
|
"MappedView access out of bounds/alignment (off={off}, n={n}, len={})",
|
||||||
|
self.len
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic `u32` load at `off` (must be 4-aligned) — the cross-process sync accessor.
|
||||||
|
#[inline]
|
||||||
|
pub fn load_u32(&self, off: usize, order: Ordering) -> u32 {
|
||||||
|
self.check(off, 4, 4);
|
||||||
|
// SAFETY: `off` is in-bounds + 4-aligned per `check`, and the page-aligned mapping stays
|
||||||
|
// valid while `&self` lives; an `AtomicU32` view over shared memory is the defined way to
|
||||||
|
// race the peer process.
|
||||||
|
unsafe { (*(self.base.add(off) as *const AtomicU32)).load(order) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic `u32` store at `off` (must be 4-aligned).
|
||||||
|
#[inline]
|
||||||
|
pub fn store_u32(&self, off: usize, v: u32, order: Ordering) {
|
||||||
|
self.check(off, 4, 4);
|
||||||
|
// SAFETY: as `load_u32` — in-bounds, aligned, valid for `&self`'s lifetime.
|
||||||
|
unsafe { (*(self.base.add(off) as *const AtomicU32)).store(v, order) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic `u64` load at `off` (must be 8-aligned).
|
||||||
|
#[inline]
|
||||||
|
pub fn load_u64(&self, off: usize, order: Ordering) -> u64 {
|
||||||
|
self.check(off, 8, 8);
|
||||||
|
// SAFETY: as `load_u32`, with 8-byte size/alignment checked.
|
||||||
|
unsafe { (*(self.base.add(off) as *const AtomicU64)).load(order) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain byte read at `off` (bulk-region accessor — protocol-guarded, see the type docs).
|
||||||
|
#[inline]
|
||||||
|
pub fn read_u8(&self, off: usize) -> u8 {
|
||||||
|
self.check(off, 1, 1);
|
||||||
|
// SAFETY: in-bounds per `check`; a one-byte read cannot tear.
|
||||||
|
unsafe { *self.base.add(off) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain byte write at `off`.
|
||||||
|
#[inline]
|
||||||
|
pub fn write_u8(&self, off: usize, v: u8) {
|
||||||
|
self.check(off, 1, 1);
|
||||||
|
// SAFETY: in-bounds per `check`; a one-byte write cannot tear.
|
||||||
|
unsafe { *self.base.add(off) = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain (unaligned) `u16` read at `off`.
|
||||||
|
#[inline]
|
||||||
|
pub fn read_u16(&self, off: usize) -> u16 {
|
||||||
|
self.check(off, 2, 1);
|
||||||
|
// SAFETY: in-bounds per `check`; `read_unaligned` has no alignment requirement.
|
||||||
|
unsafe { core::ptr::read_unaligned(self.base.add(off) as *const u16) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain (unaligned) `u32` read at `off` — the bulk-region accessor for a DATA-section scalar
|
||||||
|
/// (host-written state / a driver-written publish counter; consistency comes from the channel
|
||||||
|
/// protocol's seq fences, not from this access, exactly as on the host side).
|
||||||
|
#[inline]
|
||||||
|
pub fn read_u32(&self, off: usize) -> u32 {
|
||||||
|
self.check(off, 4, 1);
|
||||||
|
// SAFETY: in-bounds per `check`; `read_unaligned` has no alignment requirement.
|
||||||
|
unsafe { core::ptr::read_unaligned(self.base.add(off) as *const u32) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain (unaligned) `u32` write at `off` (bulk-region accessor).
|
||||||
|
#[inline]
|
||||||
|
pub fn write_u32(&self, off: usize, v: u32) {
|
||||||
|
self.check(off, 4, 1);
|
||||||
|
// SAFETY: in-bounds per `check`; `write_unaligned` has no alignment requirement.
|
||||||
|
unsafe { core::ptr::write_unaligned(self.base.add(off) as *mut u32, v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain (unaligned) `i16` read at `off`.
|
||||||
|
#[inline]
|
||||||
|
pub fn read_i16(&self, off: usize) -> i16 {
|
||||||
|
self.check(off, 2, 1);
|
||||||
|
// SAFETY: in-bounds per `check`; `read_unaligned` has no alignment requirement.
|
||||||
|
unsafe { core::ptr::read_unaligned(self.base.add(off) as *const i16) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `dst.len()` bytes out of the view starting at `off`.
|
||||||
|
pub fn read_bytes(&self, off: usize, dst: &mut [u8]) {
|
||||||
|
self.check(off, dst.len(), 1);
|
||||||
|
// SAFETY: the source range is in-bounds per `check`; `dst` is a live exclusive borrow of
|
||||||
|
// `dst.len()` writable bytes and cannot overlap the foreign mapping.
|
||||||
|
unsafe { core::ptr::copy_nonoverlapping(self.base.add(off), dst.as_mut_ptr(), dst.len()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `src` into the view starting at `off`.
|
||||||
|
pub fn write_bytes(&self, off: usize, src: &[u8]) {
|
||||||
|
self.check(off, src.len(), 1);
|
||||||
|
// SAFETY: the destination range is in-bounds per `check`; `src` is a live borrow that
|
||||||
|
// cannot overlap the foreign mapping.
|
||||||
|
unsafe { core::ptr::copy_nonoverlapping(src.as_ptr(), self.base.add(off), src.len()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MappedView {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `base` is the live view from `MapViewOfFile`, unmapped exactly once (here).
|
||||||
|
unsafe {
|
||||||
|
UnmapViewOfFile(self.base as *const c_void);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a raw handle VALUE owned by this process — the sealed channel's adopt-on-success step
|
||||||
|
/// (the mapped view keeps the section alive after the close). Closing a value that is not a live
|
||||||
|
/// handle of this process is a logic error the OS rejects (returns FALSE); it is not memory-unsafe.
|
||||||
|
pub fn close_handle_value(value: u64) {
|
||||||
|
if value == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `CloseHandle` validates the value against this process's handle table; no memory is
|
||||||
|
// dereferenced through it.
|
||||||
|
unsafe { CloseHandle(value as usize as *mut c_void) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lock-free cell holding the driver's adopted DATA view as a **leaked** `&'static MappedView`.
|
||||||
|
/// [`set`](Self::set) leaks the new view (and abandons the old one) instead of ever unmapping:
|
||||||
|
/// a concurrent framework callback may still be reading through a previously-returned reference, so
|
||||||
|
/// the mapping must never be torn down — a deliberate, bounded leak (one small view per delivery,
|
||||||
|
/// at most a handful per pad lifetime).
|
||||||
|
pub struct ViewCell(AtomicPtr<MappedView>);
|
||||||
|
|
||||||
|
impl Default for ViewCell {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewCell {
|
||||||
|
pub const fn new() -> ViewCell {
|
||||||
|
ViewCell(AtomicPtr::new(core::ptr::null_mut()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current view, if one was published. The `'static` lifetime is real: published views are
|
||||||
|
/// leaked and never unmapped.
|
||||||
|
pub fn get(&self) -> Option<&'static MappedView> {
|
||||||
|
let p = self.0.load(Ordering::Acquire);
|
||||||
|
// SAFETY: `p` is either null or a `Box::leak`ed `MappedView` published by `set`, which is
|
||||||
|
// never dropped or unmapped — so the reference is valid for the process lifetime.
|
||||||
|
(!p.is_null()).then(|| unsafe { &*p })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish `view`, leaking it (and abandoning — NOT freeing — any previous view; see the type
|
||||||
|
/// docs for why the old mapping must stay alive).
|
||||||
|
pub fn set(&self, view: MappedView) {
|
||||||
|
let leaked: &'static mut MappedView = Box::leak(Box::new(view));
|
||||||
|
self.0.swap(leaked, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
//! Safe(ly-contracted) helpers over the WDF request/memory/property DDIs the pad drivers use. The
|
||||||
|
//! pattern: a framework callback converts its raw `WDFREQUEST` into a [`Request`] token **once**
|
||||||
|
//! (`unsafe`, the framework's validity guarantee is the contract); every operation after that is a
|
||||||
|
//! safe method, and completion consumes the token so a request cannot be completed twice or used
|
||||||
|
//! after completion from safe code.
|
||||||
|
|
||||||
|
use wdk_sys::{
|
||||||
|
NTSTATUS, WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, WDFMEMORY, WDFQUEUE, WDFREQUEST,
|
||||||
|
call_unsafe_wdf_function_binding,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS;
|
||||||
|
/// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (the const isn't re-exported at the
|
||||||
|
/// wdk_sys root; the value is stable WDM).
|
||||||
|
const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn nt_success(s: NTSTATUS) -> bool {
|
||||||
|
s >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validity token for one framework-delivered `WDFREQUEST`. Not `Copy`/`Clone`: completing or
|
||||||
|
/// forwarding consumes it, so safe code cannot touch a request the framework already owns again.
|
||||||
|
pub struct Request(WDFREQUEST);
|
||||||
|
|
||||||
|
impl Request {
|
||||||
|
/// Wrap the raw request handed to the current framework callback.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `raw` must be the live, framework-provided `WDFREQUEST` of the callback invocation this is
|
||||||
|
/// called from (WDF owns handle validity; a forged/dangling handle is framework UB).
|
||||||
|
pub unsafe fn new(raw: WDFREQUEST) -> Request {
|
||||||
|
Request(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete the request with `status` (consumes the token — the framework owns it afterwards).
|
||||||
|
pub fn complete(self, status: NTSTATUS) {
|
||||||
|
// SAFETY: `self.0` is the live callback request per `Request::new`'s contract, not yet
|
||||||
|
// completed or forwarded (both consume the token).
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, self.0, status) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `src` into the request's (buffered) output buffer and set the completed byte count.
|
||||||
|
/// Returns the status to complete with (`STATUS_INVALID_BUFFER_SIZE` if the buffer is short).
|
||||||
|
pub fn copy_to_output(&self, src: &[u8]) -> NTSTATUS {
|
||||||
|
let mut mem: WDFMEMORY = core::ptr::null_mut();
|
||||||
|
// SAFETY: `self.0` is the live callback request; `mem` receives the memory handle.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, self.0, &mut mem)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
let mut outlen: usize = 0;
|
||||||
|
// SAFETY: `mem` is the valid memory object just retrieved; `outlen` receives its size.
|
||||||
|
let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) };
|
||||||
|
if outlen < src.len() {
|
||||||
|
return STATUS_INVALID_BUFFER_SIZE;
|
||||||
|
}
|
||||||
|
// SAFETY: `mem` is valid and at least `src.len()` bytes; `src` is a live borrow.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(
|
||||||
|
WdfMemoryCopyFromBuffer,
|
||||||
|
mem,
|
||||||
|
0usize,
|
||||||
|
src.as_ptr() as *mut core::ffi::c_void,
|
||||||
|
src.len()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
// SAFETY: `self.0` is the live callback request.
|
||||||
|
unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfRequestSetInformation, self.0, src.len() as u64)
|
||||||
|
};
|
||||||
|
0 // STATUS_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request's input buffer: up to `cap` bytes copied out, plus the buffer's TRUE length.
|
||||||
|
/// `Err(status)` if the input memory can't be retrieved (propagate as the completion status).
|
||||||
|
pub fn input_bytes(&self, cap: usize) -> Result<(Vec<u8>, usize), NTSTATUS> {
|
||||||
|
let mut inmem: WDFMEMORY = core::ptr::null_mut();
|
||||||
|
// SAFETY: `self.0` is the live callback request; `inmem` receives the memory handle.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, self.0, &mut inmem)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
return Err(st);
|
||||||
|
}
|
||||||
|
let mut len: usize = 0;
|
||||||
|
// SAFETY: `inmem` is the valid memory object just retrieved; `len` receives its size.
|
||||||
|
let p = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut len) }
|
||||||
|
as *const u8;
|
||||||
|
if p.is_null() {
|
||||||
|
return Ok((Vec::new(), 0));
|
||||||
|
}
|
||||||
|
let n = len.min(cap);
|
||||||
|
// SAFETY: `p` is valid for `len` bytes per `WdfMemoryGetBuffer`; we read `n <= len`.
|
||||||
|
let bytes = unsafe { core::slice::from_raw_parts(p, n) }.to_vec();
|
||||||
|
Ok((bytes, len))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request's output-buffer LENGTH (0 if unavailable) — UMDF HID marshalling carries the
|
||||||
|
/// output-report id in it.
|
||||||
|
pub fn output_buffer_len(&self) -> usize {
|
||||||
|
let mut outmem: WDFMEMORY = core::ptr::null_mut();
|
||||||
|
// SAFETY: `self.0` is the live callback request; output memory is optional here.
|
||||||
|
if !nt_success(unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, self.0, &mut outmem)
|
||||||
|
}) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut outlen: usize = 0;
|
||||||
|
// SAFETY: `outmem` is the valid memory object just retrieved; `outlen` receives its size.
|
||||||
|
let _ =
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, outmem, &mut outlen) };
|
||||||
|
outlen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the completed-bytes information field (for paths that complete with a length but no
|
||||||
|
/// output copy, e.g. echoing an output report's length).
|
||||||
|
pub fn set_information(&self, info: u64) {
|
||||||
|
// SAFETY: `self.0` is the live callback request.
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfRequestSetInformation, self.0, info) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward the request to a manual queue. On success the framework owns it (the token is
|
||||||
|
/// consumed by value — the caller cannot touch the request again); on failure the token is
|
||||||
|
/// handed back with the status so the caller completes it. (`Request` has no `Drop`, so the
|
||||||
|
/// consumed-on-success token simply falls out of scope — nothing to run.)
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `queue` must be a live manual `WDFQUEUE` of the same device (e.g. the one created in
|
||||||
|
/// `EvtDeviceAdd` and stashed in a static).
|
||||||
|
pub unsafe fn forward_to_queue(self, queue: WDFQUEUE) -> Result<(), (Request, NTSTATUS)> {
|
||||||
|
// SAFETY: `self.0` is the live callback request; `queue` is live per this fn's contract.
|
||||||
|
let st =
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, self.0, queue) };
|
||||||
|
if nt_success(st) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err((self, st))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the next pended request off a manual queue (`None` when empty).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `queue` must be a live manual `WDFQUEUE` (e.g. the timer's parent object).
|
||||||
|
pub unsafe fn retrieve_next_request(queue: WDFQUEUE) -> Option<Request> {
|
||||||
|
let mut request: WDFREQUEST = core::ptr::null_mut();
|
||||||
|
// SAFETY: `queue` is live per this fn's contract; `request` receives the next pended request.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfIoQueueRetrieveNextRequest, queue, &mut request)
|
||||||
|
};
|
||||||
|
// SAFETY: on success `request` is a live framework request this caller now services — the
|
||||||
|
// exact contract `Request::new` requires.
|
||||||
|
nt_success(st).then(|| unsafe { Request::new(request) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the pad index the host stamped into the device Location (`pszDeviceLocation`), a
|
||||||
|
/// NUL-terminated UTF-16 decimal string. Defaults to 0 (single-pad) if absent. (The WDFMEMORY is
|
||||||
|
/// device-parented and freed by the framework at device teardown — one small alloc per device add.)
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `device` must be the live `WDFDEVICE` created in the current `EvtDeviceAdd`.
|
||||||
|
pub unsafe fn query_location_index(device: WDFDEVICE) -> u32 {
|
||||||
|
let mut mem: wdk_sys::WDFMEMORY = core::ptr::null_mut();
|
||||||
|
// SAFETY: `device` is live per this fn's contract; property = LocationInformation; pool ignored
|
||||||
|
// in UMDF; `mem` receives the handle.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(
|
||||||
|
WdfDeviceAllocAndQueryProperty,
|
||||||
|
device,
|
||||||
|
DEVICE_PROPERTY_LOCATION_INFORMATION,
|
||||||
|
0,
|
||||||
|
WDF_NO_OBJECT_ATTRIBUTES,
|
||||||
|
&mut mem
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !nt_success(st) || mem.is_null() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut len: usize = 0;
|
||||||
|
// SAFETY: `mem` is the valid memory object just allocated; `len` receives its size.
|
||||||
|
let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) }
|
||||||
|
as *const u16;
|
||||||
|
if buf.is_null() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let units = (len / 2).min(8);
|
||||||
|
// SAFETY: `buf` is valid for `len` bytes per `WdfMemoryGetBuffer`; we read `units * 2 <= len`.
|
||||||
|
let chars = unsafe { core::slice::from_raw_parts(buf, units) };
|
||||||
|
let mut idx: u32 = 0;
|
||||||
|
let mut any = false;
|
||||||
|
for &c in chars {
|
||||||
|
if c == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (0x30..=0x39).contains(&c) {
|
||||||
|
idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any { idx } else { 0 }
|
||||||
|
}
|
||||||
@@ -42,8 +42,10 @@ AddReg=pf_vdisplay_HardwareDeviceSettings
|
|||||||
[pf_vdisplay_HardwareDeviceSettings]
|
[pf_vdisplay_HardwareDeviceSettings]
|
||||||
HKR, , "UpperFilters", %REG_MULTI_SZ%, "IndirectKmd"
|
HKR, , "UpperFilters", %REG_MULTI_SZ%, "IndirectKmd"
|
||||||
HKR, "WUDF", "DeviceGroupId", %REG_SZ%, "pfVDisplayGroup"
|
HKR, "WUDF", "DeviceGroupId", %REG_SZ%, "pfVDisplayGroup"
|
||||||
; Let the host (LocalSystem service) + admins open the control device for the ADD/REMOVE/PING IOCTLs.
|
; Only the host (LocalSystem service) + admins may open the control device. Deliberately NO Everyone
|
||||||
HKR, , "Security", , "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;WD)"
|
; ACE (SudoVDA ships one for its user-mode host): the control plane creates/removes monitors and
|
||||||
|
; bootstraps the sealed frame channel (IOCTL_SET_FRAME_CHANNEL), so it is not for unprivileged callers.
|
||||||
|
HKR, , "Security", , "D:P(A;;GA;;;SY)(A;;GA;;;BA)"
|
||||||
|
|
||||||
[pf_vdisplay_Install.NT.Services]
|
[pf_vdisplay_Install.NT.Services]
|
||||||
AddService=WUDFRd,0x000001fa,WUDFRD_ServiceInstall
|
AddService=WUDFRd,0x000001fa,WUDFRD_ServiceInstall
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ struct SendAdapter(iddcx::IDDCX_ADAPTER);
|
|||||||
// SAFETY: an opaque IddCx handle, used only as an argument to IddCx DDIs (themselves the synchronisation
|
// SAFETY: an opaque IddCx handle, used only as an argument to IddCx DDIs (themselves the synchronisation
|
||||||
// point) — never dereferenced in Rust. Storing it across threads in a OnceLock is sound.
|
// point) — never dereferenced in Rust. Storing it across threads in a OnceLock is sound.
|
||||||
unsafe impl Send for SendAdapter {}
|
unsafe impl Send for SendAdapter {}
|
||||||
|
// SAFETY: as above — the handle is only ever passed by value to IddCx DDIs, never dereferenced, so
|
||||||
|
// shared `&SendAdapter` access across threads is sound.
|
||||||
unsafe impl Sync for SendAdapter {}
|
unsafe impl Sync for SendAdapter {}
|
||||||
|
|
||||||
static ADAPTER: OnceLock<SendAdapter> = OnceLock::new();
|
static ADAPTER: OnceLock<SendAdapter> = OnceLock::new();
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ pub unsafe extern "C" fn parse_monitor_description(
|
|||||||
p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION,
|
p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION,
|
||||||
p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION,
|
p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION,
|
||||||
) -> NTSTATUS {
|
) -> NTSTATUS {
|
||||||
// SAFETY: framework-provided in/out args, valid for the call.
|
// SAFETY: the framework supplies a valid, live input-args pointer for the call.
|
||||||
let in_args = unsafe { &*p_in };
|
let in_args = unsafe { &*p_in };
|
||||||
|
// SAFETY: the framework supplies a valid, live output-args pointer for the call.
|
||||||
let out_args = unsafe { &mut *p_out };
|
let out_args = unsafe { &mut *p_out };
|
||||||
// SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes.
|
// SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes.
|
||||||
let edid = unsafe {
|
let edid = unsafe {
|
||||||
@@ -100,8 +101,9 @@ pub unsafe extern "C" fn parse_monitor_description2(
|
|||||||
p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION2,
|
p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION2,
|
||||||
p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION,
|
p_out: *mut iddcx::IDARG_OUT_PARSEMONITORDESCRIPTION,
|
||||||
) -> NTSTATUS {
|
) -> NTSTATUS {
|
||||||
// SAFETY: framework-provided in/out args, valid for the call.
|
// SAFETY: the framework supplies a valid, live input-args pointer for the call.
|
||||||
let in_args = unsafe { &*p_in };
|
let in_args = unsafe { &*p_in };
|
||||||
|
// SAFETY: the framework supplies a valid, live output-args pointer for the call.
|
||||||
let out_args = unsafe { &mut *p_out };
|
let out_args = unsafe { &mut *p_out };
|
||||||
// SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes.
|
// SAFETY: the framework supplies a valid EDID buffer of `DataSize` bytes.
|
||||||
let edid = unsafe {
|
let edid = unsafe {
|
||||||
@@ -156,8 +158,9 @@ pub unsafe extern "C" fn monitor_query_modes(
|
|||||||
p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES,
|
p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES,
|
||||||
p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES,
|
p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES,
|
||||||
) -> NTSTATUS {
|
) -> NTSTATUS {
|
||||||
// SAFETY: framework-provided in/out args, valid for the call.
|
// SAFETY: the framework supplies a valid, live input-args pointer for the call.
|
||||||
let in_args = unsafe { &*p_in };
|
let in_args = unsafe { &*p_in };
|
||||||
|
// SAFETY: the framework supplies a valid, live output-args pointer for the call.
|
||||||
let out_args = unsafe { &mut *p_out };
|
let out_args = unsafe { &mut *p_out };
|
||||||
let Some(modes) = crate::monitor::modes_for_object(monitor) else {
|
let Some(modes) = crate::monitor::modes_for_object(monitor) else {
|
||||||
return STATUS_NOT_FOUND;
|
return STATUS_NOT_FOUND;
|
||||||
@@ -183,8 +186,9 @@ pub unsafe extern "C" fn monitor_query_modes2(
|
|||||||
p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES2,
|
p_in: *const iddcx::IDARG_IN_QUERYTARGETMODES2,
|
||||||
p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES,
|
p_out: *mut iddcx::IDARG_OUT_QUERYTARGETMODES,
|
||||||
) -> NTSTATUS {
|
) -> NTSTATUS {
|
||||||
// SAFETY: framework-provided in/out args, valid for the call.
|
// SAFETY: the framework supplies a valid, live input-args pointer for the call.
|
||||||
let in_args = unsafe { &*p_in };
|
let in_args = unsafe { &*p_in };
|
||||||
|
// SAFETY: the framework supplies a valid, live output-args pointer for the call.
|
||||||
let out_args = unsafe { &mut *p_out };
|
let out_args = unsafe { &mut *p_out };
|
||||||
let Some(modes) = crate::monitor::modes_for_object(monitor) else {
|
let Some(modes) = crate::monitor::modes_for_object(monitor) else {
|
||||||
return STATUS_NOT_FOUND;
|
return STATUS_NOT_FOUND;
|
||||||
@@ -279,7 +283,8 @@ pub unsafe extern "C" fn assign_swap_chain(
|
|||||||
drop(crate::monitor::take_swap_chain_processor(monitor));
|
drop(crate::monitor::take_swap_chain_processor(monitor));
|
||||||
|
|
||||||
// The OS target id (stamped on the monitor at creation, after IddCxMonitorArrival) keys the
|
// The OS target id (stamped on the monitor at creation, after IddCxMonitorArrival) keys the
|
||||||
// per-monitor objects STEP 6's host opens. 0 (default) if the monitor isn't found.
|
// frame-channel stash STEP 6's worker attaches from (the host addresses its IOCTL_SET_FRAME_CHANNEL
|
||||||
|
// delivery by this id). 0 (default) if the monitor isn't found — the worker then never attaches.
|
||||||
let target_id = crate::monitor::target_id_for_object(monitor).unwrap_or(0);
|
let target_id = crate::monitor::target_id_for_object(monitor).unwrap_or(0);
|
||||||
|
|
||||||
if let Some(device) = crate::direct_3d_device::pooled_device(luid) {
|
if let Some(device) = crate::direct_3d_device::pooled_device(luid) {
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) {
|
|||||||
}
|
}
|
||||||
// SAFETY: `request` is the framework WDFREQUEST.
|
// SAFETY: `request` is the framework WDFREQUEST.
|
||||||
control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) },
|
control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) },
|
||||||
|
// SAFETY: `request` is the framework WDFREQUEST.
|
||||||
|
control::IOCTL_SET_FRAME_CHANNEL => unsafe { set_frame_channel(request) },
|
||||||
_ => complete(request, STATUS_NOT_FOUND),
|
_ => complete(request, STATUS_NOT_FOUND),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,11 +150,49 @@ unsafe fn add(request: WDFREQUEST) {
|
|||||||
adapter_luid_high: luid_high,
|
adapter_luid_high: luid_high,
|
||||||
target_id,
|
target_id,
|
||||||
resolved_monitor_id: monitor_id,
|
resolved_monitor_id: monitor_id,
|
||||||
|
// This WUDFHost's pid — where the host duplicates the sealed frame channel's handles INTO
|
||||||
|
// (`ProcessSharingDisabled`: this process is exclusively ours and dies with the device).
|
||||||
|
wudf_pid: std::process::id(),
|
||||||
};
|
};
|
||||||
// SAFETY: `request` is the framework WDFREQUEST.
|
// SAFETY: `request` is the framework WDFREQUEST.
|
||||||
unsafe { write_output_complete(request, &reply) };
|
unsafe { write_output_complete(request, &reply) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `IOCTL_SET_FRAME_CHANNEL`: adopt the handle values the host duplicated into this process and stash
|
||||||
|
/// them on the target monitor for the swap-chain worker to attach with. The ownership contract with
|
||||||
|
/// the host is **adopt-on-success only**: this driver owns (and eventually closes) the handles iff the
|
||||||
|
/// IOCTL completes successfully; on ANY error completion it leaves them untouched, because the host
|
||||||
|
/// reaps its remote duplicates whenever the IOCTL fails — a close on both sides would double-close
|
||||||
|
/// values the OS may already have reused for unrelated handles.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `request` is the framework `WDFREQUEST`.
|
||||||
|
unsafe fn set_frame_channel(request: WDFREQUEST) {
|
||||||
|
// SAFETY: `request` is the framework WDFREQUEST.
|
||||||
|
let Some(req) = (unsafe { read_input::<control::SetFrameChannelRequest>(request) }) else {
|
||||||
|
complete(request, STATUS_INVALID_PARAMETER);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// A malformed request adopts nothing (no FrameChannel is built, so no Drop can close anything).
|
||||||
|
let Some(ch) = crate::frame_transport::FrameChannel::from_request(&req) else {
|
||||||
|
complete(request, STATUS_INVALID_PARAMETER);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match crate::monitor::set_frame_channel(req.target_id, ch) {
|
||||||
|
Ok(()) => complete(request, STATUS_SUCCESS),
|
||||||
|
Err(ch) => {
|
||||||
|
dbglog!(
|
||||||
|
"[pf-vd] SET_FRAME_CHANNEL: no monitor with target_id {} — rejecting (host reaps the handles)",
|
||||||
|
req.target_id
|
||||||
|
);
|
||||||
|
// NOT adopted: disarm the channel so its Drop does NOT close the handles (see the contract
|
||||||
|
// above — the host's error path reaps them remotely).
|
||||||
|
ch.into_unowned();
|
||||||
|
complete(request, STATUS_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `IOCTL_REMOVE`: depart + drop the monitor for the given session id.
|
/// `IOCTL_REMOVE`: depart + drop the monitor for the given session id.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
|
|||||||
@@ -123,11 +123,11 @@ static DEVICE_POOL: Mutex<Option<(i64, Arc<Direct3DDevice>)>> = Mutex::new(None)
|
|||||||
pub fn pooled_device(luid: LUID) -> Option<Arc<Direct3DDevice>> {
|
pub fn pooled_device(luid: LUID) -> Option<Arc<Direct3DDevice>> {
|
||||||
let key = (i64::from(luid.HighPart) << 32) | i64::from(luid.LowPart);
|
let key = (i64::from(luid.HighPart) << 32) | i64::from(luid.LowPart);
|
||||||
let mut pool = DEVICE_POOL.lock().ok()?;
|
let mut pool = DEVICE_POOL.lock().ok()?;
|
||||||
if let Some((k, dev)) = pool.as_ref() {
|
if let Some((k, dev)) = pool.as_ref()
|
||||||
if *k == key {
|
&& *k == key
|
||||||
|
{
|
||||||
return Some(dev.clone());
|
return Some(dev.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
match Direct3DDevice::init(luid) {
|
match Direct3DDevice::init(luid) {
|
||||||
Ok(d) => {
|
Ok(d) => {
|
||||||
let a = Arc::new(d);
|
let a = Arc::new(d);
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
//! STEP 6 — IDD-push frame publisher (DRIVER side).
|
//! STEP 6 — IDD-push frame publisher (DRIVER side), attached over the **sealed channel**.
|
||||||
//!
|
//!
|
||||||
//! The restricted WUDFHost token canNOT create named kernel objects (proven on the RTX box: it can't
|
//! The restricted WUDFHost token canNOT create named kernel objects — and since the frame channel
|
||||||
//! even write a world-writable file), so — exactly like the gamepad UMDF drivers
|
//! carries whole-desktop pixels, the objects are not merely host-created but **unnamed**: nothing to
|
||||||
//! (`crates/punktfunk-host/src/inject/dualsense_windows.rs`: *"the host creates the section, privileged,
|
//! enumerate, open by name, or pre-create ("squat"). The **host** creates the shared header +
|
||||||
//! with a permissive SDDL so the WUDFHost can open it; the driver maps it"*) — the **host** creates the
|
//! frame-ready event + ring of keyed-mutex textures with no names, duplicates the handles INTO this
|
||||||
//! shared header + frame-ready event + ring of keyed-mutex textures, and the driver only **OPENS** them.
|
//! WUDFHost process (`DuplicateHandle` — SYSTEM can, we can't reciprocate, which is why the host is the
|
||||||
//! The driver writes its actual render-adapter LUID + a status code back into the host-created header (our
|
//! broker), and delivers the handle VALUES over `IOCTL_SET_FRAME_CHANNEL` ([`crate::control`] stashes
|
||||||
//! only driver-visibility channel: UMDF hides OutputDebugString in ETW and the token can't write files),
|
//! them per monitor as a [`FrameChannel`]). The swap-chain worker picks the stash up and attaches with
|
||||||
//! then copies each acquired swap-chain surface into the next ring slot and signals the host.
|
//! [`FramePublisher::from_channel`]. Only the two endpoint processes ever hold a handle to any frame
|
||||||
|
//! object — see `design/idd-push-security.md`.
|
||||||
//!
|
//!
|
||||||
//! Host counterpart: `crates/punktfunk-host/src/capture/idd_push.rs`. The shared `SharedHeader` layout,
|
//! The driver writes its actual render-adapter LUID + a status code back into the host-created header
|
||||||
//! the [`FrameToken`] packing, the `Global\` object-name scheme, the `MAGIC`/`RING_LEN` and the
|
//! (our only driver-visibility channel: UMDF hides OutputDebugString in ETW and the token can't write
|
||||||
//! `DRV_STATUS_*` codes are NOT hand-duplicated here: both sides `use pf_driver_proto::frame::*`, which
|
//! files), then copies each acquired swap-chain surface into the next ring slot and signals the host.
|
||||||
//! OWNS the contract (with `const` size asserts so any drift is a compile error).
|
|
||||||
//!
|
//!
|
||||||
//! Ported from the proven oracle (`packaging/windows/vdisplay-driver/pf-vdisplay/src/frame_transport.rs`).
|
//! Host counterpart: `crates/punktfunk-host/src/capture/windows/idd_push.rs`. The shared `SharedHeader`
|
||||||
//! Differences from the oracle:
|
//! layout, the [`FrameToken`] packing, the `MAGIC`/`RING_LEN`, the `DRV_STATUS_*` codes and the
|
||||||
//! * the layout/consts/names/token come from `pf_driver_proto::frame` instead of being re-declared;
|
//! channel-delivery struct are NOT hand-duplicated here: both sides `use pf_driver_proto::{control,
|
||||||
//! * `dbglog!` replaces `log::info!`;
|
//! frame}`, which OWNS the contract (with `const` size asserts so any drift is a compile error).
|
||||||
//! * the optional fixed-name `Global\pfvd-dbg` `DebugBlock` bring-up channel is SKIPPED (not on the data
|
|
||||||
//! path). FOLLOW-UP: if the host bring-up diagnostics are needed again, port the oracle's `DebugBlock`
|
|
||||||
//! here too (it is owned by `idd_push.rs`, not the proto).
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use pf_driver_proto::control::SetFrameChannelRequest;
|
||||||
use pf_driver_proto::frame::{
|
use pf_driver_proto::frame::{
|
||||||
DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, FrameToken, MAGIC, RING_LEN,
|
DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, FrameToken, MAGIC, RING_LEN,
|
||||||
SharedHeader, event_name, header_name, texture_name,
|
SharedHeader,
|
||||||
};
|
};
|
||||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
@@ -34,28 +32,95 @@ use windows::Win32::Graphics::Direct3D11::{
|
|||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex;
|
use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex;
|
||||||
use windows::Win32::System::Memory::{
|
use windows::Win32::System::Memory::{
|
||||||
FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, OpenFileMappingW,
|
FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, UnmapViewOfFile,
|
||||||
UnmapViewOfFile,
|
|
||||||
};
|
};
|
||||||
use windows::Win32::System::Threading::{OpenEventW, SYNCHRONIZATION_ACCESS_RIGHTS, SetEvent};
|
use windows::Win32::System::Threading::SetEvent;
|
||||||
use windows::core::{HSTRING, Interface};
|
use windows::core::Interface;
|
||||||
|
|
||||||
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` — passed to `OpenSharedResourceByName` (matches the host's
|
|
||||||
/// `CreateSharedHandle` access). Kept local: it is a `OpenSharedResourceByName` arg, not part of the
|
|
||||||
/// proto contract. (Same value the host uses in `idd_push.rs`.)
|
|
||||||
const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
|
|
||||||
/// SYNCHRONIZE | EVENT_MODIFY_STATE — the driver does not wait on the event, only SIGNALS it.
|
|
||||||
const EVENT_ACCESS: u32 = 0x0010_0000 | 0x0002;
|
|
||||||
/// `WAIT_TIMEOUT` as an HRESULT — `AcquireSync` returns this when the slot is held by the consumer.
|
/// `WAIT_TIMEOUT` as an HRESULT — `AcquireSync` returns this when the slot is held by the consumer.
|
||||||
const WAIT_TIMEOUT_HRESULT: i32 = 0x0000_0102;
|
const WAIT_TIMEOUT_HRESULT: i32 = 0x0000_0102;
|
||||||
|
|
||||||
|
/// One monitor's sealed-channel bootstrap: the handle VALUES the host duplicated into THIS process
|
||||||
|
/// (`IOCTL_SET_FRAME_CHANNEL`). Owning a `FrameChannel` means owning those handles — exactly one of
|
||||||
|
/// {the monitor stash ([`crate::monitor`]), a [`FramePublisher`] under construction} holds it at any
|
||||||
|
/// time, and `Drop` closes every entry not consumed, so a replaced/unmatched/failed delivery can never
|
||||||
|
/// leak entries in the WUDFHost handle table. A `0` field means "taken" (or never valid) and is skipped.
|
||||||
|
pub struct FrameChannel {
|
||||||
|
/// The ring generation these textures belong to (checked against the header at attach).
|
||||||
|
generation: u32,
|
||||||
|
ring_len: u32,
|
||||||
|
header: u64,
|
||||||
|
event: u64,
|
||||||
|
textures: [u64; RING_LEN as usize],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameChannel {
|
||||||
|
/// Validate + adopt the handle values from the host's IOCTL. `None` on a malformed request (bad
|
||||||
|
/// `ring_len`, zero handles) — the caller completes with `STATUS_INVALID_PARAMETER` and nothing is
|
||||||
|
/// adopted (a zero value is never treated as a handle).
|
||||||
|
pub fn from_request(req: &SetFrameChannelRequest) -> Option<Self> {
|
||||||
|
if req.ring_len == 0 || req.ring_len > RING_LEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if req.header_handle == 0
|
||||||
|
|| req.event_handle == 0
|
||||||
|
|| req.texture_handles[..req.ring_len as usize].contains(&0)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
generation: req.generation,
|
||||||
|
ring_len: req.ring_len,
|
||||||
|
header: req.header_handle,
|
||||||
|
event: req.event_handle,
|
||||||
|
textures: req.texture_handles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a handle value out of the channel: the caller now owns it; `Drop` skips the zeroed slot.
|
||||||
|
fn take(v: &mut u64) -> HANDLE {
|
||||||
|
HANDLE(core::mem::take(v) as usize as *mut core::ffi::c_void)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disarm without closing anything — for the adopt-on-success-only contract: a delivery rejected
|
||||||
|
/// with an error completion was never adopted, and the HOST reaps its remote duplicates on that
|
||||||
|
/// error, so closing here too would double-close (see `crate::control::set_frame_channel`).
|
||||||
|
pub fn into_unowned(mut self) {
|
||||||
|
self.header = 0;
|
||||||
|
self.event = 0;
|
||||||
|
self.textures = [0; RING_LEN as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FrameChannel {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
for v in [&mut self.header, &mut self.event]
|
||||||
|
.into_iter()
|
||||||
|
.chain(self.textures.iter_mut())
|
||||||
|
{
|
||||||
|
if *v != 0 {
|
||||||
|
let h = Self::take(v);
|
||||||
|
// SAFETY: `h` is a live handle the host duplicated into this process for us to own; it
|
||||||
|
// was not consumed (non-zero), so this is its sole close.
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NB: `FrameChannel` is plain integers, so it is auto-`Send` — it crosses from the control-plane
|
||||||
|
// dispatch thread (stash) to the swap-chain worker (attach) with `MONITOR_MODES` serializing the
|
||||||
|
// hand-off; no manual impl needed (handle values are process-global tokens, not thread-affine).
|
||||||
|
|
||||||
struct Slot {
|
struct Slot {
|
||||||
tex: ID3D11Texture2D,
|
tex: ID3D11Texture2D,
|
||||||
mutex: IDXGIKeyedMutex,
|
mutex: IDXGIKeyedMutex,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes acquired swap-chain surfaces into the HOST-created ring. Owned by the swap-chain processor
|
/// Publishes acquired swap-chain surfaces into the HOST-created ring. Owned by the swap-chain processor
|
||||||
/// thread; attached lazily once the host has created the shared objects.
|
/// thread; attached lazily once the host's channel delivery lands in the monitor stash.
|
||||||
pub struct FramePublisher {
|
pub struct FramePublisher {
|
||||||
context: ID3D11DeviceContext,
|
context: ID3D11DeviceContext,
|
||||||
map: HANDLE,
|
map: HANDLE,
|
||||||
@@ -70,7 +135,8 @@ pub struct FramePublisher {
|
|||||||
ring_format: u32,
|
ring_format: u32,
|
||||||
/// The ring generation this publisher attached to. The host BUMPS the header generation when it
|
/// The ring generation this publisher attached to. The host BUMPS the header generation when it
|
||||||
/// recreates the ring at a new format mid-session (the display's HDR mode flipped) — [`Self::is_stale`]
|
/// recreates the ring at a new format mid-session (the display's HDR mode flipped) — [`Self::is_stale`]
|
||||||
/// detects that so `run_core` re-attaches to the new-format textures instead of dropping every frame.
|
/// detects that so `run_core` re-attaches to the new ring (whose channel the host re-delivers)
|
||||||
|
/// instead of dropping every frame.
|
||||||
generation: u32,
|
generation: u32,
|
||||||
/// Set when a surface is dropped for a descriptor mismatch (a game mode-set the display), cleared on a
|
/// Set when a surface is dropped for a descriptor mismatch (a game mode-set the display), cleared on a
|
||||||
/// matched publish — throttles the drop log to once per mismatch episode (game-capture bug GB1).
|
/// matched publish — throttles the drop log to once per mismatch episode (game-capture bug GB1).
|
||||||
@@ -81,102 +147,99 @@ pub struct FramePublisher {
|
|||||||
unsafe impl Send for FramePublisher {}
|
unsafe impl Send for FramePublisher {}
|
||||||
|
|
||||||
impl FramePublisher {
|
impl FramePublisher {
|
||||||
/// Try ONCE to attach to the host-created shared objects. Returns `Err` cheaply if the host hasn't
|
/// Attach to the host ring from a delivered [`FrameChannel`]. Consumes the channel: on ANY failure
|
||||||
/// created/published them yet — the drain loop retries periodically, so a non-IDD-push session just
|
/// every handle is closed (taken ones explicitly, the rest by the channel's `Drop`) and the host
|
||||||
/// keeps draining with no stall. All early-return paths clean up the handles/mapping they opened
|
/// re-delivers on the next recreate — there is nothing to poll, so failure is terminal for THIS
|
||||||
/// explicitly (raw-handle style, no RAII — matches the rest of this driver).
|
/// delivery (the host's `wait_for_attach` sees the status code and fails the session open). All
|
||||||
pub fn try_open(
|
/// early-return paths clean up explicitly (raw-handle style, no RAII — matches the rest of this
|
||||||
target_id: u32,
|
/// driver).
|
||||||
|
pub fn from_channel(
|
||||||
|
mut channel: FrameChannel,
|
||||||
render_luid_low: u32,
|
render_luid_low: u32,
|
||||||
render_luid_high: i32,
|
render_luid_high: i32,
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
context: &ID3D11DeviceContext,
|
context: &ID3D11DeviceContext,
|
||||||
) -> windows::core::Result<Self> {
|
) -> windows::core::Result<Self> {
|
||||||
// 1. Open the host-created header (RW). Err if the host hasn't created it yet.
|
let ring_len = channel.ring_len;
|
||||||
// SAFETY: a plain Win32 call; the name HSTRING is valid for the call (`?` returns on failure).
|
|
||||||
let map = unsafe {
|
// 1. Map the header from the duplicated section handle (ours from here on).
|
||||||
OpenFileMappingW(
|
let map = FrameChannel::take(&mut channel.header);
|
||||||
FILE_MAP_ALL_ACCESS.0,
|
// SAFETY: `map` is the live section handle the host duplicated into this process; mapping
|
||||||
false,
|
// size_of::<SharedHeader>() bytes of it (the host created the mapping at >= that size). The null
|
||||||
&HSTRING::from(header_name(target_id)),
|
// `view.Value` is checked below.
|
||||||
)?
|
|
||||||
};
|
|
||||||
// SAFETY: `map` is the just-opened file mapping; mapping size_of::<SharedHeader>() bytes of it
|
|
||||||
// (the host created the mapping at >= that size). The null `view.Value` is checked below.
|
|
||||||
let view = unsafe {
|
let view = unsafe {
|
||||||
|
// Read/write only — the host now duplicates the header handle with least access
|
||||||
|
// (`SECTION_MAP_READ | SECTION_MAP_WRITE`), so `FILE_MAP_ALL_ACCESS` would exceed the
|
||||||
|
// granted rights and fail. We read the layout + write status/publish-token fields; RW covers it.
|
||||||
MapViewOfFile(
|
MapViewOfFile(
|
||||||
map,
|
map,
|
||||||
FILE_MAP_ALL_ACCESS,
|
FILE_MAP_READ | FILE_MAP_WRITE,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
core::mem::size_of::<SharedHeader>(),
|
core::mem::size_of::<SharedHeader>(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if view.Value.is_null() {
|
if view.Value.is_null() {
|
||||||
// SAFETY: `map` is the just-opened mapping handle, closed once here on the error path.
|
let err = windows::core::Error::from_win32();
|
||||||
|
// SAFETY: `map` is the taken section handle, closed once here on the error path (the rest of
|
||||||
|
// `channel` closes via its Drop).
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = CloseHandle(map);
|
let _ = CloseHandle(map);
|
||||||
}
|
}
|
||||||
return Err(windows::core::Error::from_win32());
|
return Err(err);
|
||||||
}
|
}
|
||||||
let header = view.Value.cast::<SharedHeader>();
|
let header = view.Value.cast::<SharedHeader>();
|
||||||
|
|
||||||
// 2. Report our render adapter to the host immediately (lets it detect a mismatch).
|
// 2. Report our render adapter to the host immediately (lets it detect a mismatch).
|
||||||
// SAFETY: `header` points to the mapped, non-null host header (>= size_of::<SharedHeader>() bytes);
|
// SAFETY: `header` points to the mapped, non-null host header (>= size_of::<SharedHeader>()
|
||||||
// these scalar writes are within it. The host opened the section with a permissive SDDL for us.
|
// bytes); these scalar writes are within it.
|
||||||
unsafe {
|
unsafe {
|
||||||
(*header).driver_render_luid_low = render_luid_low;
|
(*header).driver_render_luid_low = render_luid_low;
|
||||||
(*header).driver_render_luid_high = render_luid_high;
|
(*header).driver_render_luid_high = render_luid_high;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. The host sets magic==MAGIC only once the ring textures exist. Not ready → retry later.
|
// 3. The host stamps magic==MAGIC BEFORE delivering the channel, and this channel's generation
|
||||||
// SAFETY: `header` is the mapped host header; `magic` lives within it and is read atomically
|
// must match the header's CURRENT generation — a mismatch means the host recreated the ring
|
||||||
// (Acquire) to pair with the host's Release store once the ring textures are published.
|
// again before we attached (a fresh delivery is on its way); drop this stale one.
|
||||||
let magic = unsafe {
|
// SAFETY: `header` is the mapped host header; `magic`/`generation` live within it and are read
|
||||||
(*(core::ptr::addr_of!((*header).magic) as *const AtomicU32)).load(Ordering::Acquire)
|
// atomically (Acquire) to pair with the host's Release publishes.
|
||||||
};
|
let (magic, header_gen) = unsafe {
|
||||||
if magic != MAGIC {
|
(
|
||||||
// SAFETY: `header`/`map` are the live mapped view + handle; unmapped + closed once on this path.
|
(*(core::ptr::addr_of!((*header).magic) as *const AtomicU32))
|
||||||
unsafe {
|
.load(Ordering::Acquire),
|
||||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
(*(core::ptr::addr_of!((*header).generation) as *const AtomicU32))
|
||||||
Value: header.cast(),
|
.load(Ordering::Acquire),
|
||||||
});
|
|
||||||
let _ = CloseHandle(map);
|
|
||||||
}
|
|
||||||
return Err(windows::core::Error::from_win32());
|
|
||||||
}
|
|
||||||
// SAFETY: `header` is the mapped host header; these scalar fields live within it.
|
|
||||||
let (generation, ring_len) =
|
|
||||||
unsafe { ((*header).generation, (*header).ring_len.min(RING_LEN)) };
|
|
||||||
|
|
||||||
// 4. Open the event (SYNCHRONIZE | EVENT_MODIFY_STATE so we can SetEvent).
|
|
||||||
// SAFETY: a plain Win32 call; the name HSTRING is valid for the call.
|
|
||||||
let event = match unsafe {
|
|
||||||
OpenEventW(
|
|
||||||
SYNCHRONIZATION_ACCESS_RIGHTS(EVENT_ACCESS),
|
|
||||||
false,
|
|
||||||
&HSTRING::from(event_name(target_id)),
|
|
||||||
)
|
)
|
||||||
} {
|
};
|
||||||
Ok(e) => e,
|
if magic != MAGIC || header_gen != channel.generation {
|
||||||
Err(e) => {
|
dbglog!(
|
||||||
// SAFETY: `header`/`map` are the live mapped view + handle; unmapped + closed once here.
|
"[pf-vd] frame-push(driver): dropping channel delivery (magic ok: {}, channel gen {} vs header gen {header_gen})",
|
||||||
|
magic == MAGIC,
|
||||||
|
channel.generation
|
||||||
|
);
|
||||||
|
// SAFETY: `header`/`map` are the live mapped view + taken handle; unmapped + closed once on
|
||||||
|
// this path.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||||
Value: header.cast(),
|
Value: header.cast(),
|
||||||
});
|
});
|
||||||
let _ = CloseHandle(map);
|
let _ = CloseHandle(map);
|
||||||
}
|
}
|
||||||
return Err(e);
|
// E_BOUNDS — stand-in for "stale delivery"; the caller only drops the attempt.
|
||||||
|
return Err(windows::core::HRESULT(0x8000_000Bu32 as i32).into());
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// 5. Open device1 + the ring textures the host created (same render adapter required).
|
// 4. The frame-ready event (duplicated with the host handle's full access, so SetEvent works).
|
||||||
|
let event = FrameChannel::take(&mut channel.event);
|
||||||
|
|
||||||
|
// 5. Open device1 + the ring textures from their duplicated shared handles (same render adapter
|
||||||
|
// required). Each NT handle is closed right after the open — the COM object holds its own
|
||||||
|
// reference, and the HOST keeps the resource alive with its own handle.
|
||||||
let device1: ID3D11Device1 = match device.cast() {
|
let device1: ID3D11Device1 = match device.cast() {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// SAFETY: `header` is the mapped host header (status write within it); `event`/`map` are the
|
// SAFETY: `header` is the mapped host header (status write within it); `event`/`map` are
|
||||||
// live handles, all released once on this error path.
|
// the taken live handles, all released once on this error path.
|
||||||
unsafe {
|
unsafe {
|
||||||
(*header).driver_status = DRV_STATUS_NO_DEVICE1;
|
(*header).driver_status = DRV_STATUS_NO_DEVICE1;
|
||||||
let _ = CloseHandle(event);
|
let _ = CloseHandle(event);
|
||||||
@@ -189,34 +252,35 @@ impl FramePublisher {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut slots = Vec::new();
|
let mut slots = Vec::new();
|
||||||
for k in 0..ring_len {
|
// Take each texture handle one at a time (NOT the whole array up front), so an error return
|
||||||
let name = HSTRING::from(texture_name(target_id, generation, k));
|
// mid-loop still lets `channel`'s Drop close every not-yet-taken handle.
|
||||||
// SAFETY: `device1` is a live ID3D11Device1; the name HSTRING is valid for the call.
|
for value in channel.textures.iter_mut().take(ring_len as usize) {
|
||||||
|
let tex_handle = FrameChannel::take(value);
|
||||||
|
// SAFETY: `device1` is a live ID3D11Device1; `tex_handle` is the duplicated shared NT handle
|
||||||
|
// for this ring texture.
|
||||||
let opened: windows::core::Result<ID3D11Texture2D> =
|
let opened: windows::core::Result<ID3D11Texture2D> =
|
||||||
unsafe { device1.OpenSharedResourceByName(&name, DXGI_SHARED_RESOURCE_RW) };
|
unsafe { device1.OpenSharedResource1(tex_handle) };
|
||||||
match opened {
|
// SAFETY: `tex_handle` is ours (taken above) and no longer needed whether the open succeeded
|
||||||
|
// (the COM object holds the resource) or failed — close it exactly once here.
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(tex_handle);
|
||||||
|
}
|
||||||
|
let failed = match opened {
|
||||||
Ok(tex) => match tex.cast::<IDXGIKeyedMutex>() {
|
Ok(tex) => match tex.cast::<IDXGIKeyedMutex>() {
|
||||||
Ok(mutex) => slots.push(Slot { tex, mutex }),
|
Ok(mutex) => {
|
||||||
Err(e) => {
|
slots.push(Slot { tex, mutex });
|
||||||
// SAFETY: `header` is the mapped host header (status writes within it); `event`/`map`
|
None
|
||||||
// are the live handles, all released once on this error path.
|
|
||||||
unsafe {
|
|
||||||
(*header).driver_status = DRV_STATUS_TEX_FAIL;
|
|
||||||
(*header).driver_status_detail = e.code().0 as u32;
|
|
||||||
let _ = CloseHandle(event);
|
|
||||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
|
||||||
Value: header.cast(),
|
|
||||||
});
|
|
||||||
let _ = CloseHandle(map);
|
|
||||||
}
|
|
||||||
return Err(e);
|
|
||||||
}
|
}
|
||||||
|
Err(e) => Some(e),
|
||||||
},
|
},
|
||||||
Err(e) => {
|
// Most likely a render-adapter mismatch (the host made the textures on a different GPU
|
||||||
// Most likely a render-adapter mismatch (the host made the textures on a different
|
// than the swap-chain renders on). Tell the host so it can report it.
|
||||||
// GPU than the swap-chain renders on). Tell the host so it can report it.
|
Err(e) => Some(e),
|
||||||
|
};
|
||||||
|
if let Some(e) = failed {
|
||||||
// SAFETY: `header` is the mapped host header (status writes within it); `event`/`map`
|
// SAFETY: `header` is the mapped host header (status writes within it); `event`/`map`
|
||||||
// are the live handles, all released once on this error path.
|
// are the taken live handles, all released once on this error path (the not-yet-taken
|
||||||
|
// texture handles close via `channel`'s Drop).
|
||||||
unsafe {
|
unsafe {
|
||||||
(*header).driver_status = DRV_STATUS_TEX_FAIL;
|
(*header).driver_status = DRV_STATUS_TEX_FAIL;
|
||||||
(*header).driver_status_detail = e.code().0 as u32;
|
(*header).driver_status_detail = e.code().0 as u32;
|
||||||
@@ -229,14 +293,13 @@ impl FramePublisher {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: `header` is the mapped host header; the status field lives within it.
|
// SAFETY: `header` is the mapped host header; the status field lives within it.
|
||||||
unsafe {
|
unsafe {
|
||||||
(*header).driver_status = DRV_STATUS_OPENED;
|
(*header).driver_status = DRV_STATUS_OPENED;
|
||||||
}
|
}
|
||||||
dbglog!(
|
dbglog!(
|
||||||
"[pf-vd] frame-push(driver): attached to host ring gen {generation} ({ring_len} slots)"
|
"[pf-vd] frame-push(driver): attached to host ring gen {header_gen} ({ring_len} slots, sealed channel)"
|
||||||
);
|
);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
context: context.clone(),
|
context: context.clone(),
|
||||||
@@ -248,7 +311,7 @@ impl FramePublisher {
|
|||||||
seq: 0,
|
seq: 0,
|
||||||
// SAFETY: `header` is the mapped host header; `dxgi_format` lives within it.
|
// SAFETY: `header` is the mapped host header; `dxgi_format` lives within it.
|
||||||
ring_format: unsafe { (*header).dxgi_format },
|
ring_format: unsafe { (*header).dxgi_format },
|
||||||
generation,
|
generation: header_gen,
|
||||||
mismatch_logged: false,
|
mismatch_logged: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -261,8 +324,8 @@ impl FramePublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// True once the host has recreated the ring (bumped the header generation) — e.g. the display's HDR
|
/// True once the host has recreated the ring (bumped the header generation) — e.g. the display's HDR
|
||||||
/// mode flipped, so the ring format changed (FP16 ⇄ BGRA) and the texture names now carry a new
|
/// mode flipped, so the ring format changed (FP16 ⇄ BGRA) and a fresh channel delivery is coming.
|
||||||
/// generation. `run_core` drops the publisher on this so it re-attaches to the new ring.
|
/// `run_core` drops the publisher on this so it re-attaches to the new ring.
|
||||||
pub fn is_stale(&self) -> bool {
|
pub fn is_stale(&self) -> bool {
|
||||||
// SAFETY: `self.header` stays mapped for the publisher's lifetime; `generation` lives within it and
|
// SAFETY: `self.header` stays mapped for the publisher's lifetime; `generation` lives within it and
|
||||||
// is read atomically (Acquire) to pair with the host's Release bump on a mid-session ring recreate.
|
// is read atomically (Acquire) to pair with the host's Release bump on a mid-session ring recreate.
|
||||||
@@ -338,8 +401,8 @@ impl FramePublisher {
|
|||||||
}
|
}
|
||||||
.pack();
|
.pack();
|
||||||
self.latest_cell().store(latest, Ordering::Release);
|
self.latest_cell().store(latest, Ordering::Release);
|
||||||
// SAFETY: `self.event` is the live host-created frame-ready event we opened with
|
// SAFETY: `self.event` is the live host-created frame-ready event, duplicated into
|
||||||
// EVENT_MODIFY_STATE; signalling it wakes the host consumer.
|
// this process with the creator's access; signalling it wakes the host consumer.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = SetEvent(self.event);
|
let _ = SetEvent(self.event);
|
||||||
}
|
}
|
||||||
@@ -357,10 +420,11 @@ impl FramePublisher {
|
|||||||
impl Drop for FramePublisher {
|
impl Drop for FramePublisher {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// Slots FIRST (release the shared textures + keyed mutexes), THEN unmap the header, THEN the
|
// Slots FIRST (release the shared textures + keyed mutexes), THEN unmap the header, THEN the
|
||||||
// handles.
|
// handles — nothing of the channel outlives the publisher (teardown invariant,
|
||||||
|
// `design/idd-push-security.md`).
|
||||||
self.slots.clear();
|
self.slots.clear();
|
||||||
// SAFETY: drop runs once; `self.header` (if non-null) is the live mapped view and `self.event`/
|
// SAFETY: drop runs once; `self.header` (if non-null) is the live mapped view and `self.event`/
|
||||||
// `self.map` are the live handles this publisher opened — each unmapped/closed exactly once here.
|
// `self.map` are the live handles this publisher owns — each unmapped/closed exactly once here.
|
||||||
unsafe {
|
unsafe {
|
||||||
if !self.header.is_null() {
|
if !self.header.is_null() {
|
||||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
|
|
||||||
#![allow(non_snake_case, clippy::missing_safety_doc)]
|
#![allow(non_snake_case, clippy::missing_safety_doc)]
|
||||||
// P0 lint (audit §8): an unsafe op inside an `unsafe fn` must be in an explicit `unsafe {}` block, so the
|
// P0 lint (audit §8): an unsafe op inside an `unsafe fn` must be in an explicit `unsafe {}` block, so the
|
||||||
// fn-level `unsafe` never silently blesses the whole body. (The per-site `// SAFETY:` discipline already
|
// fn-level `unsafe` never silently blesses the whole body, AND every `unsafe {}` must carry a `// SAFETY:`
|
||||||
// landed in STEP 8.)
|
// proof. An IddCx display driver is inherently FFI-bound (D3D11 / IddCx DDIs / cross-process shared
|
||||||
|
// textures), so it can't be unsafe-FREE the way the gamepad drivers now are (their logic moved onto the
|
||||||
|
// safe `pf_umdf_util` layer); these gates make it unsafe-AUDITED instead, and stop it regressing.
|
||||||
#![deny(unsafe_op_in_unsafe_fn)]
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
mod log;
|
mod log;
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ pub fn log(s: &str) {
|
|||||||
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
|
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
|
||||||
}
|
}
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
if let Some(m) = file_appender() {
|
if let Some(m) = file_appender()
|
||||||
if let Ok(mut f) = m.lock() {
|
&& let Ok(mut f) = m.lock()
|
||||||
|
{
|
||||||
let _ = writeln!(f, "{s}");
|
let _ = writeln!(f, "{s}");
|
||||||
let _ = f.flush();
|
let _ = f.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! dbglog {
|
macro_rules! dbglog {
|
||||||
($($a:tt)*) => { $crate::log::log(&::std::format!($($a)*)) };
|
($($a:tt)*) => { $crate::log::log(&::std::format!($($a)*)) };
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ pub struct MonitorObject {
|
|||||||
/// The live swap-chain drain worker, set by `assign_swap_chain` and dropped (RAII-joins the worker
|
/// The live swap-chain drain worker, set by `assign_swap_chain` and dropped (RAII-joins the worker
|
||||||
/// thread) by `unassign_swap_chain` / departure (STEP 5).
|
/// thread) by `unassign_swap_chain` / departure (STEP 5).
|
||||||
pub swap_chain_processor: Option<crate::swap_chain_processor::SwapChainProcessor>,
|
pub swap_chain_processor: Option<crate::swap_chain_processor::SwapChainProcessor>,
|
||||||
|
/// The host's sealed-channel delivery (`IOCTL_SET_FRAME_CHANNEL`) awaiting pickup by the swap-chain
|
||||||
|
/// worker ([`take_frame_channel`]). Exactly one owner per delivery: replacing or dropping the entry
|
||||||
|
/// closes an unconsumed channel's handles via [`FrameChannel`]'s `Drop`, so no delivery can leak
|
||||||
|
/// handles in the WUDFHost table whatever the monitor's fate.
|
||||||
|
pub frame_channel: Option<crate::frame_transport::FrameChannel>,
|
||||||
/// When the entry was created — the watchdog skips still-initializing monitors.
|
/// When the entry was created — the watchdog skips still-initializing monitors.
|
||||||
pub created_at: Instant,
|
pub created_at: Instant,
|
||||||
}
|
}
|
||||||
@@ -256,8 +261,8 @@ pub fn modes_for_object(object: iddcx::IDDCX_MONITOR) -> Option<Vec<Mode>> {
|
|||||||
.map(|m| m.modes.clone())
|
.map(|m| m.modes.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The OS target id stamped on the monitor whose handle matches (used by `assign_swap_chain` to name the
|
/// The OS target id stamped on the monitor whose handle matches (used by `assign_swap_chain` to key the
|
||||||
/// shared-ring objects). `None` if the monitor isn't found.
|
/// frame-channel stash for its worker). `None` if the monitor isn't found.
|
||||||
pub fn target_id_for_object(object: iddcx::IDDCX_MONITOR) -> Option<u32> {
|
pub fn target_id_for_object(object: iddcx::IDDCX_MONITOR) -> Option<u32> {
|
||||||
MONITOR_MODES
|
MONITOR_MODES
|
||||||
.lock()
|
.lock()
|
||||||
@@ -267,6 +272,52 @@ pub fn target_id_for_object(object: iddcx::IDDCX_MONITOR) -> Option<u32> {
|
|||||||
.map(|m| m.target_id)
|
.map(|m| m.target_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stash a host frame-channel delivery on the monitor with `target_id` (an ARRIVED monitor — a pending
|
||||||
|
/// entry's `target_id` is still 0, which the host can never send since OS target ids are non-zero).
|
||||||
|
/// Replacing an unconsumed delivery drops it → its handles close (it WAS adopted by a prior success).
|
||||||
|
/// `Err(ch)` if no such monitor exists — the caller must NOT close those handles (the host only sees
|
||||||
|
/// the error status and reaps its remote duplicates itself; closing here too would double-close values
|
||||||
|
/// the OS may have reused).
|
||||||
|
pub fn set_frame_channel(
|
||||||
|
target_id: u32,
|
||||||
|
ch: crate::frame_transport::FrameChannel,
|
||||||
|
) -> Result<(), crate::frame_transport::FrameChannel> {
|
||||||
|
if target_id == 0 {
|
||||||
|
return Err(ch);
|
||||||
|
}
|
||||||
|
let mut lock = lock_monitors();
|
||||||
|
if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) {
|
||||||
|
m.frame_channel = Some(ch);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take (remove) the pending frame-channel delivery for `target_id`, transferring handle ownership to
|
||||||
|
/// the caller (the swap-chain worker's attach). `None` until the host delivers one.
|
||||||
|
pub fn take_frame_channel(target_id: u32) -> Option<crate::frame_transport::FrameChannel> {
|
||||||
|
if target_id == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
lock_monitors()
|
||||||
|
.iter_mut()
|
||||||
|
.find(|m| m.target_id == target_id)?
|
||||||
|
.frame_channel
|
||||||
|
.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is a frame-channel delivery pending for `target_id`? The swap-chain worker treats a pending
|
||||||
|
/// delivery as NEWEST-WINS: it supersedes an attached publisher, because the host only re-delivers
|
||||||
|
/// after (re)creating the ring — and a retry-created ring is a DIFFERENT header mapping, whose
|
||||||
|
/// generation bump an old publisher (mapped to the previous header) can never observe.
|
||||||
|
pub fn has_frame_channel(target_id: u32) -> bool {
|
||||||
|
target_id != 0
|
||||||
|
&& lock_monitors()
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.target_id == target_id && m.frame_channel.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a swap-chain processor on the monitor whose handle matches, returning any PREVIOUS processor
|
/// Install a swap-chain processor on the monitor whose handle matches, returning any PREVIOUS processor
|
||||||
/// for the caller to drop OUTSIDE the lock. Dropping a processor RAII-joins its worker thread, so it must
|
/// for the caller to drop OUTSIDE the lock. Dropping a processor RAII-joins its worker thread, so it must
|
||||||
/// never happen while holding `MONITOR_MODES` (the worker would block the whole control plane / risk a
|
/// never happen while holding `MONITOR_MODES` (the worker would block the whole control plane / risk a
|
||||||
@@ -351,6 +402,7 @@ pub fn create_monitor(
|
|||||||
adapter_luid_low: 0,
|
adapter_luid_low: 0,
|
||||||
adapter_luid_high: 0,
|
adapter_luid_high: 0,
|
||||||
swap_chain_processor: None,
|
swap_chain_processor: None,
|
||||||
|
frame_channel: None,
|
||||||
created_at: Instant::now(),
|
created_at: Instant::now(),
|
||||||
});
|
});
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ pub struct SwapChainProcessor {
|
|||||||
// SAFETY: Raw ptr is managed by external library; access is serialised by the worker thread + the
|
// SAFETY: Raw ptr is managed by external library; access is serialised by the worker thread + the
|
||||||
// terminate flag.
|
// terminate flag.
|
||||||
unsafe impl Send for SwapChainProcessor {}
|
unsafe impl Send for SwapChainProcessor {}
|
||||||
|
// SAFETY: as above — the raw pointer is only touched by the serialised worker, so a shared
|
||||||
|
// `&SwapChainProcessor` reference exposes no unsynchronised access.
|
||||||
unsafe impl Sync for SwapChainProcessor {}
|
unsafe impl Sync for SwapChainProcessor {}
|
||||||
|
|
||||||
impl SwapChainProcessor {
|
impl SwapChainProcessor {
|
||||||
@@ -223,10 +225,11 @@ impl SwapChainProcessor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 6 IDD-push: lazily ATTACH to the HOST-created shared ring. The restricted UMDF token can't
|
// STEP 6 IDD-push: lazily ATTACH to the HOST-created shared ring over the SEALED channel. The
|
||||||
// create named objects, so the host creates the header + event + textures and we only OPEN them
|
// frame objects are unnamed — the host duplicates their handles into this process and delivers
|
||||||
// once they appear (`try_open`). Until then we just drain — exactly the STEP-5 behaviour — so a
|
// the values via IOCTL_SET_FRAME_CHANNEL, which the control plane stashes on our monitor
|
||||||
// non-IDD-push session never stalls. Retried every ~30 loop iterations.
|
// (`monitor::take_frame_channel`). Until a delivery lands we just drain — exactly the STEP-5
|
||||||
|
// behaviour — so a non-IDD-push session never stalls. The stash is polled every ~30 iterations.
|
||||||
let mut publisher: Option<FramePublisher> = None;
|
let mut publisher: Option<FramePublisher> = None;
|
||||||
let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first loop iteration
|
let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first loop iteration
|
||||||
|
|
||||||
@@ -243,25 +246,34 @@ impl SwapChainProcessor {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The host recreates the shared ring (new format) mid-session when the display's HDR mode
|
// Re-attach triggers, either of:
|
||||||
// flips — it bumps the header generation. Detect that and drop the publisher so we re-attach to
|
// * `is_stale` — the host recreated the ring mid-session (HDR flip): it bumps OUR header's
|
||||||
// the new-format textures below; otherwise we'd keep CopyResource'ing into the stale ring, whose
|
// generation and re-delivers; without dropping here we'd keep CopyResource'ing into the
|
||||||
// format now mismatches the surface → the publish() format-guard drops every frame and the
|
// stale ring, whose format now mismatches the surface → the publish() format-guard drops
|
||||||
// stream freezes until the next swap-chain recreate.
|
// every frame and the stream freezes until the next swap-chain recreate.
|
||||||
if publisher.as_ref().is_some_and(FramePublisher::is_stale) {
|
// * a PENDING delivery (newest-wins) — a host build-retry creates a whole NEW ring with a
|
||||||
|
// DIFFERENT header mapping; the old publisher's header never changes, so `is_stale` can't
|
||||||
|
// fire. The host only delivers after fully (re)creating a ring, so a pending delivery
|
||||||
|
// always supersedes whatever we're attached to.
|
||||||
|
if publisher.as_ref().is_some_and(FramePublisher::is_stale)
|
||||||
|
|| (publisher.is_some() && crate::monitor::has_frame_channel(target_id))
|
||||||
|
{
|
||||||
publisher = None;
|
publisher = None;
|
||||||
frames_since_try = u32::MAX; // re-attach immediately
|
frames_since_try = u32::MAX; // re-attach immediately
|
||||||
}
|
}
|
||||||
// Lazy-attach (rate-limited) at the loop TOP so we keep trying even while the display is idle
|
// Lazy-attach (rate-limited) at the loop TOP so we keep trying even while the display is idle
|
||||||
// (E_PENDING / no frames presented yet), not only when a frame is acquired. `try_open` is a
|
// (E_PENDING / no frames presented yet), not only when a frame is acquired. Checking the
|
||||||
// cheap OpenFileMapping that fails fast until the host has created the ring.
|
// stash is a cheap mutex peek that stays empty until the host's channel delivery lands; a
|
||||||
|
// taken delivery is consumed whether the attach succeeds or not (on failure its handles are
|
||||||
|
// closed, the host's wait-for-attach reads the status code, and any retry is a NEW delivery).
|
||||||
if publisher.is_none() {
|
if publisher.is_none() {
|
||||||
if frames_since_try >= 30 {
|
if frames_since_try >= 30 {
|
||||||
frames_since_try = 0;
|
frames_since_try = 0;
|
||||||
|
if let Some(channel) = crate::monitor::take_frame_channel(target_id) {
|
||||||
// `if let Ok` (not a `match` with an empty `Err` arm) keeps clippy's `single_match`
|
// `if let Ok` (not a `match` with an empty `Err` arm) keeps clippy's `single_match`
|
||||||
// happy under `-D warnings`; semantics are identical — attach on success, retry on Err.
|
// happy under `-D warnings`; attach on success, drop the delivery on Err.
|
||||||
if let Ok(p) = FramePublisher::try_open(
|
if let Ok(p) = FramePublisher::from_channel(
|
||||||
target_id,
|
channel,
|
||||||
render_luid_low,
|
render_luid_low,
|
||||||
render_luid_high,
|
render_luid_high,
|
||||||
&device.device,
|
&device.device,
|
||||||
@@ -269,6 +281,7 @@ impl SwapChainProcessor {
|
|||||||
) {
|
) {
|
||||||
publisher = Some(p);
|
publisher = Some(p);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
frames_since_try += 1;
|
frames_since_try += 1;
|
||||||
}
|
}
|
||||||
@@ -337,13 +350,13 @@ impl SwapChainProcessor {
|
|||||||
if !raw.is_null() {
|
if !raw.is_null() {
|
||||||
// SAFETY: `raw` is IddCx's live surface pointer (valid until the next
|
// SAFETY: `raw` is IddCx's live surface pointer (valid until the next
|
||||||
// ReleaseAndAcquire); `from_raw_borrowed` does not consume the refcount.
|
// ReleaseAndAcquire); `from_raw_borrowed` does not consume the refcount.
|
||||||
if let Some(res) = unsafe { IDXGIResource::from_raw_borrowed(&raw) } {
|
if let Some(res) = unsafe { IDXGIResource::from_raw_borrowed(&raw) }
|
||||||
if let Ok(tex) = res.cast::<ID3D11Texture2D>() {
|
&& let Ok(tex) = res.cast::<ID3D11Texture2D>()
|
||||||
|
{
|
||||||
p.publish(&tex);
|
p.publish(&tex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: driver is loaded; `swap_chain` is valid.
|
// SAFETY: driver is loaded; `swap_chain` is valid.
|
||||||
let hr = unsafe { wdk_iddcx::IddCxSwapChainFinishedProcessingFrame(swap_chain) };
|
let hr = unsafe { wdk_iddcx::IddCxSwapChainFinishedProcessingFrame(swap_chain) };
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ wdk-build.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
wdk.workspace = true
|
wdk.workspace = true
|
||||||
wdk-sys.workspace = true
|
wdk-sys.workspace = true
|
||||||
|
pf-driver-proto.workspace = true
|
||||||
|
pf-umdf-util.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ instance (= player slot 0–3) with `CreateFile`, and polls it with buffered IOC
|
|||||||
**System** setup class;
|
**System** setup class;
|
||||||
- registers the XUSB interface with `WdfDeviceCreateDeviceInterface(device, &XUSB_GUID, NULL)`;
|
- registers the XUSB interface with `WdfDeviceCreateDeviceInterface(device, &XUSB_GUID, NULL)`;
|
||||||
- answers the XUSB IOCTLs (all `METHOD_BUFFERED`, delivered to user mode by the reflector) from
|
- answers the XUSB IOCTLs (all `METHOD_BUFFERED`, delivered to user mode by the reflector) from
|
||||||
controller state the host publishes into a shared section `Global\pfxusb-shm-0`; a game's rumble
|
controller state the host publishes into an **unnamed** shared DATA section reached over the
|
||||||
(`SET_STATE`) is published back for the host to forward to the client.
|
**sealed pad channel** (`design/gamepad-channel-sealing.md`): the host duplicates the section
|
||||||
|
handle into this driver's WUDFHost, bootstrapped via the named `Global\pfxusb-boot-<index>`
|
||||||
|
mailbox (`pf_driver_proto::gamepad::PadBootstrap`); a game's rumble (`SET_STATE`) is published
|
||||||
|
back for the host to forward to the client.
|
||||||
|
|
||||||
The WAIT_* IOCTLs return `STATUS_INVALID_DEVICE_REQUEST`, which makes `xinput1_4` fall back to
|
The WAIT_* IOCTLs return `STATUS_INVALID_DEVICE_REQUEST`, which makes `xinput1_4` fall back to
|
||||||
synchronous `GET_STATE` polling — so no manual queue / timer is needed for classic XInput. (WGI/
|
synchronous `GET_STATE` polling — so no manual queue / timer is needed for classic XInput. (WGI/
|
||||||
@@ -37,11 +40,13 @@ GameInput admission additionally needs a `xinputhid` `UpperFilters` registry tri
|
|||||||
`wButtons` is the `XINPUT_GAMEPAD_*` bitmap (DPAD_UP `0x0001` … A `0x1000` B `0x2000` X `0x4000`
|
`wButtons` is the `XINPUT_GAMEPAD_*` bitmap (DPAD_UP `0x0001` … A `0x1000` B `0x2000` X `0x4000`
|
||||||
Y `0x8000`). `dwPacketNumber` (GET_STATE `[5]`) must increment whenever the payload changes.
|
Y `0x8000`). `dwPacketNumber` (GET_STATE `[5]`) must increment whenever the payload changes.
|
||||||
|
|
||||||
## Shared-memory layout `Global\pfxusb-shm-0` (64 B) — host writes state, driver writes rumble
|
## Shared-memory layout (unnamed DATA section, 64 B) — host writes state, driver writes rumble
|
||||||
|
|
||||||
|
`pf_driver_proto::gamepad::XusbShm` (the crate owns the offsets; both sides compile against it):
|
||||||
`magic u32 @0` (`"PFXU"` `0x55584650`) · `packet u32 @4` (host bumps → dwPacketNumber) · `wButtons u16
|
`magic u32 @0` (`"PFXU"` `0x55584650`) · `packet u32 @4` (host bumps → dwPacketNumber) · `wButtons u16
|
||||||
@8` · `LT @10` · `RT @11` · `LX/LY/RX/RY i16 @12/@14/@16/@18` · `rumble_seq u32 @24` (driver bumps) ·
|
@8` · `LT @10` · `RT @11` · `LX/LY/RX/RY i16 @12/@14/@16/@18` · `rumble_seq u32 @24` (driver bumps) ·
|
||||||
`large @28` · `small @29`.
|
`large @28` · `small @29` · health marks `@32/@36` · `pad_index u32 @40` (validated against the
|
||||||
|
devnode's Location index when the delivered handle is mapped).
|
||||||
|
|
||||||
## Validated live (2026-06-22, maintainer's RTX test box)
|
## Validated live (2026-06-22, maintainer's RTX test box)
|
||||||
|
|
||||||
@@ -66,7 +71,8 @@ the whole build/sign/stage flow in CI. The manual steps:
|
|||||||
## Host integration (done)
|
## Host integration (done)
|
||||||
|
|
||||||
`crates/punktfunk-host/src/inject/windows/gamepad_windows.rs` is the Windows `GamepadManager` (used by
|
`crates/punktfunk-host/src/inject/windows/gamepad_windows.rs` is the Windows `GamepadManager` (used by
|
||||||
`PadBackend::Xbox360`): it SwDeviceCreate's the `pf_xusb` companion, maps `pfxusb-shm-<index>`, writes
|
`PadBackend::Xbox360`): it SwDeviceCreate's the `pf_xusb` companion, delivers the unnamed DATA
|
||||||
|
section over the sealed channel (`PadChannel`), writes
|
||||||
the XInput state from the client's gamepad frame (already XInput-convention) and forwards rumble. There
|
the XInput state from the client's gamepad frame (already XInput-convention) and forwards rumble. There
|
||||||
is **no ViGEmBus dependency** anymore. The driver is built + signed from source in CI
|
is **no ViGEmBus dependency** anymore. The driver is built + signed from source in CI
|
||||||
(`build-gamepad-drivers.ps1`) and installed by the Inno Setup installer via
|
(`build-gamepad-drivers.ps1`) and installed by the Inno Setup installer via
|
||||||
@@ -75,8 +81,8 @@ is **no ViGEmBus dependency** anymore. The driver is built + signed from source
|
|||||||
## Multi-pad
|
## Multi-pad
|
||||||
|
|
||||||
The host stamps each pad's index into the device Location (`pszDeviceLocation`); the driver reads it
|
The host stamps each pad's index into the device Location (`pszDeviceLocation`); the driver reads it
|
||||||
via `WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` in EvtDeviceAdd and maps its own
|
via `WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` in EvtDeviceAdd and polls its own
|
||||||
`pfxusb-shm-<index>`. `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) gives each pad its own
|
`pfxusb-boot-<index>` bootstrap mailbox (the delivered DATA section's `pad_index` is validated against it). `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) gives each pad its own
|
||||||
WUDFHost, so the per-pad `SHM_INDEX` static doesn't collide. Validated live: two pads → two distinct
|
WUDFHost, so the per-pad `SHM_INDEX` static doesn't collide. Validated live: two pads → two distinct
|
||||||
XInput slots. (XInput assigns the player slot 0-3 by interface-enumeration order, independent of this
|
XInput slots. (XInput assigns the player slot 0-3 by interface-enumeration order, independent of this
|
||||||
index — which only routes shared memory.)
|
index — which only routes shared memory.)
|
||||||
|
|||||||
@@ -3,42 +3,39 @@
|
|||||||
//
|
//
|
||||||
// xinput1_4.dll enumerates GUID_DEVINTERFACE_XUSB, opens the Nth instance (= player slot), and polls
|
// xinput1_4.dll enumerates GUID_DEVINTERFACE_XUSB, opens the Nth instance (= player slot), and polls
|
||||||
// it with buffered IOCTLs. We register the interface and answer those IOCTLs from controller state the
|
// it with buffered IOCTLs. We register the interface and answer those IOCTLs from controller state the
|
||||||
// host publishes into a shared-memory section (`Global\pfxusb-shm-0`); a game's rumble (SET_STATE) is
|
// host publishes into a shared DATA section; a game's rumble (SET_STATE) is published back for the
|
||||||
// published back for the host to forward. Byte formats are the source-verified xusb22 wire layout
|
// host to forward. Byte formats are the source-verified xusb22 wire layout (HIDMaestro
|
||||||
// (HIDMaestro driver/companion.c + nefarius/XInputHooker XUSB.h + ViGEm XUSB_REPORT).
|
// driver/companion.c + nefarius/XInputHooker XUSB.h + ViGEm XUSB_REPORT).
|
||||||
|
//
|
||||||
|
// The host channel is the **sealed pad channel** (design/gamepad-channel-sealing.md, proto v2): the
|
||||||
|
// DATA section (`pf_driver_proto::gamepad::XusbShm`) is UNNAMED — we reach it only through a handle
|
||||||
|
// the SYSTEM host duplicated into this WUDFHost, bootstrapped over the named `Global\pfxusb-boot-<i>`
|
||||||
|
// mailbox. The whole handshake + all shared-memory access lives in `pf_umdf_util` (audited unsafe
|
||||||
|
// layer): this crate's channel/IOCTL/state logic is 100% SAFE Rust. The only `unsafe` here is the
|
||||||
|
// unavoidable WDF setup FFI in DriverEntry/EvtDeviceAdd, each with a `// SAFETY:` proof.
|
||||||
//
|
//
|
||||||
// We answer the WAIT_* IOCTLs with STATUS_INVALID_DEVICE_REQUEST, which makes xinput1_4 fall back to
|
// We answer the WAIT_* IOCTLs with STATUS_INVALID_DEVICE_REQUEST, which makes xinput1_4 fall back to
|
||||||
// synchronous GET_STATE polling — so no manual queue / timer is needed for classic XInput.
|
// synchronous GET_STATE polling — so no manual queue / timer is needed for classic XInput.
|
||||||
|
|
||||||
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
|
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
|
||||||
|
// Every remaining `unsafe {}` (all WDF setup FFI) must carry a `// SAFETY:` proof.
|
||||||
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use core::ffi::c_void;
|
use pf_driver_proto::gamepad::XusbShm;
|
||||||
use core::sync::atomic::{AtomicU32, Ordering};
|
use pf_umdf_util::channel::{ChannelClient, ChannelConfig};
|
||||||
|
use pf_umdf_util::nt_success;
|
||||||
|
use pf_umdf_util::section::MappedView;
|
||||||
|
use pf_umdf_util::wdf::{self, Request};
|
||||||
use wdk_sys::{
|
use wdk_sys::{
|
||||||
call_unsafe_wdf_function_binding, windows::OutputDebugStringA, GUID, NTSTATUS, PCUNICODE_STRING,
|
GUID, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG,
|
||||||
PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDFDEVICE, WDFDRIVER, WDFMEMORY, WDFQUEUE, WDFREQUEST,
|
WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, WDFDRIVER, WDFQUEUE,
|
||||||
WDF_DRIVER_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES,
|
WDFREQUEST, call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
|
||||||
};
|
};
|
||||||
|
|
||||||
// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (the const isn't re-exported at the
|
|
||||||
// wdk_sys root; the value is stable WDM).
|
|
||||||
const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10;
|
|
||||||
|
|
||||||
/// The pad index this device serves (which `pfxusb-shm-<index>` section to map). The host stamps it
|
|
||||||
/// into the device Location (`pszDeviceLocation`); the driver reads it in EvtDeviceAdd. With
|
|
||||||
/// `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) each pad gets its own WUDFHost, so this
|
|
||||||
/// static is per-pad — the basis for multi-pad.
|
|
||||||
static SHM_INDEX: AtomicU32 = AtomicU32::new(0);
|
|
||||||
|
|
||||||
// ---- NTSTATUS ----
|
// ---- NTSTATUS ----
|
||||||
const STATUS_SUCCESS: NTSTATUS = 0;
|
const STATUS_SUCCESS: NTSTATUS = 0;
|
||||||
const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = 0xC000_0010u32 as NTSTATUS;
|
const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = 0xC000_0010u32 as NTSTATUS;
|
||||||
const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS;
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn nt_success(s: NTSTATUS) -> bool {
|
|
||||||
s >= 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// GUID_DEVINTERFACE_XUSB {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — what xinput1_4 enumerates + opens.
|
// GUID_DEVINTERFACE_XUSB {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — what xinput1_4 enumerates + opens.
|
||||||
const GUID_DEVINTERFACE_XUSB: GUID = GUID {
|
const GUID_DEVINTERFACE_XUSB: GUID = GUID {
|
||||||
@@ -70,27 +67,46 @@ const XUSB_VERSION: u16 = 0x0103;
|
|||||||
const WdfIoQueueDispatchParallel: i32 = 2;
|
const WdfIoQueueDispatchParallel: i32 = 2;
|
||||||
const WdfUseDefault: i32 = 2; // WDF_TRI_STATE
|
const WdfUseDefault: i32 = 2; // WDF_TRI_STATE
|
||||||
|
|
||||||
// ---- shared-memory layout (host ↔ driver), must match pf_driver_proto::gamepad::XusbShm ----
|
// ---- the sealed host channel: layouts + offsets from pf_driver_proto (drift = compile error) ----
|
||||||
// magic u32 @0 ("PFXU"); packet u32 @4 (host bumps on state change → dwPacketNumber); the XUSB_REPORT
|
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
|
||||||
// payload @8: wButtons u16 @8, bLeftTrigger @10, bRightTrigger @11, sThumbLX i16 @12, LY @14, RX @16,
|
const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
|
||||||
// RY @18; rumble_seq u32 @24 (driver bumps on SET_STATE); rumble large @28, small @29;
|
const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION;
|
||||||
// driver_proto u32 @32 (we stamp GAMEPAD_PROTO_VERSION = attach signal for the host's health check);
|
|
||||||
// driver_heartbeat u32 @36 (we bump per serviced IOCTL = the game-visible polling path moves).
|
|
||||||
const FILE_MAP_RW: u32 = 0x0002 | 0x0004;
|
|
||||||
const SHM_MAGIC: u32 = 0x5558_4650; // "PFXU" little-endian
|
|
||||||
const SHM_SIZE: usize = 64;
|
|
||||||
const GAMEPAD_PROTO_VERSION: u32 = 1; // must match pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION
|
|
||||||
|
|
||||||
unsafe extern "system" {
|
// XusbShm field offsets (host writes state, we answer XInput; we write rumble + health marks).
|
||||||
fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void;
|
const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
|
||||||
fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void;
|
const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
|
||||||
fn UnmapViewOfFile(addr: *const c_void) -> i32;
|
const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
|
||||||
fn CloseHandle(h: *mut c_void) -> i32;
|
const OFF_RT: usize = core::mem::offset_of!(XusbShm, right_trigger);
|
||||||
|
const OFF_LX: usize = core::mem::offset_of!(XusbShm, thumb_lx);
|
||||||
|
const OFF_LY: usize = core::mem::offset_of!(XusbShm, thumb_ly);
|
||||||
|
const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx);
|
||||||
|
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_LARGE: usize = core::mem::offset_of!(XusbShm, rumble_large);
|
||||||
|
const OFF_RUMBLE_SMALL: usize = core::mem::offset_of!(XusbShm, rumble_small);
|
||||||
|
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
|
||||||
|
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(XusbShm, driver_heartbeat);
|
||||||
|
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
|
||||||
|
|
||||||
|
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
||||||
|
/// this static is per-pad). All shared-memory access + the bootstrap handshake live in `pf_umdf_util`.
|
||||||
|
static CHANNEL: ChannelClient = ChannelClient::new();
|
||||||
|
|
||||||
|
/// This pad's channel config (magic/size/pad_index offset + our logger).
|
||||||
|
fn channel_cfg() -> ChannelConfig {
|
||||||
|
ChannelConfig {
|
||||||
|
tag: "pf-xusb",
|
||||||
|
boot_name_prefix: "Global\\pfxusb-boot-",
|
||||||
|
data_magic: SHM_MAGIC,
|
||||||
|
data_size: SHM_SIZE,
|
||||||
|
pad_index_off: OFF_PAD_INDEX,
|
||||||
|
log,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log(s: &str) {
|
fn log(s: &str) {
|
||||||
if let Ok(c) = std::ffi::CString::new(s) {
|
if let Ok(c) = std::ffi::CString::new(s) {
|
||||||
// SAFETY: c is a valid null-terminated string for the duration of the call.
|
// SAFETY: `c` is a valid NUL-terminated string for the duration of the call.
|
||||||
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
|
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
|
||||||
}
|
}
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -110,11 +126,11 @@ pub unsafe extern "system" fn driver_entry(
|
|||||||
registry_path: PCUNICODE_STRING,
|
registry_path: PCUNICODE_STRING,
|
||||||
) -> NTSTATUS {
|
) -> NTSTATUS {
|
||||||
log("[pf-xusb] DriverEntry");
|
log("[pf-xusb] DriverEntry");
|
||||||
// SAFETY: zeroed config then Size + callback set.
|
// SAFETY: a zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback.
|
||||||
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() };
|
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() };
|
||||||
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
|
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
|
||||||
config.EvtDriverDeviceAdd = Some(evt_device_add);
|
config.EvtDriverDeviceAdd = Some(evt_device_add);
|
||||||
// SAFETY: all pointers valid; provided by the loader.
|
// SAFETY: `driver`/`registry_path` are the loader-provided pointers; the config is valid.
|
||||||
unsafe {
|
unsafe {
|
||||||
call_unsafe_wdf_function_binding!(
|
call_unsafe_wdf_function_binding!(
|
||||||
WdfDriverCreate,
|
WdfDriverCreate,
|
||||||
@@ -127,56 +143,11 @@ pub unsafe extern "system" fn driver_entry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the pad index the host stamped into the device Location (`pszDeviceLocation`), a NUL-terminated
|
|
||||||
/// UTF-16 decimal string. Defaults to 0 (single-pad) if absent.
|
|
||||||
fn query_shm_index(device: WDFDEVICE) -> u32 {
|
|
||||||
let mut mem: WDFMEMORY = core::ptr::null_mut();
|
|
||||||
// SAFETY: device valid; property = LocationInformation; pool ignored in UMDF; mem receives the handle.
|
|
||||||
let st = unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(
|
|
||||||
WdfDeviceAllocAndQueryProperty,
|
|
||||||
device,
|
|
||||||
DEVICE_PROPERTY_LOCATION_INFORMATION,
|
|
||||||
0,
|
|
||||||
WDF_NO_OBJECT_ATTRIBUTES,
|
|
||||||
&mut mem
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if !nt_success(st) || mem.is_null() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let mut len: usize = 0;
|
|
||||||
// SAFETY: mem valid.
|
|
||||||
let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) }
|
|
||||||
as *const u16;
|
|
||||||
if buf.is_null() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let mut idx: u32 = 0;
|
|
||||||
let mut any = false;
|
|
||||||
for i in 0..(len / 2).min(8) {
|
|
||||||
// SAFETY: buf valid for len bytes; i < len/2.
|
|
||||||
let c = unsafe { *buf.add(i) };
|
|
||||||
if c == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (0x30..=0x39).contains(&c) {
|
|
||||||
idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32);
|
|
||||||
any = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if any {
|
|
||||||
idx
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
|
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
|
||||||
log("[pf-xusb] EvtDeviceAdd");
|
log("[pf-xusb] EvtDeviceAdd");
|
||||||
|
|
||||||
let mut device: WDFDEVICE = core::ptr::null_mut();
|
let mut device: WDFDEVICE = core::ptr::null_mut();
|
||||||
// SAFETY: device_init valid; attributes null; device receives the handle.
|
// SAFETY: `device_init` is the framework-provided init; attributes null; `device` receives it.
|
||||||
let st = unsafe {
|
let st = unsafe {
|
||||||
call_unsafe_wdf_function_binding!(
|
call_unsafe_wdf_function_binding!(
|
||||||
WdfDeviceCreate,
|
WdfDeviceCreate,
|
||||||
@@ -190,12 +161,14 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
|
|||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
|
|
||||||
let idx = query_shm_index(device);
|
// SAFETY: `device` is the live device just created — the exact contract `query_location_index`
|
||||||
SHM_INDEX.store(idx, Ordering::Relaxed);
|
// requires.
|
||||||
|
let idx = unsafe { wdf::query_location_index(device) };
|
||||||
|
CHANNEL.set_index(idx);
|
||||||
dbglog!("[pf-xusb] shm index = {idx}");
|
dbglog!("[pf-xusb] shm index = {idx}");
|
||||||
|
|
||||||
// Register the XUSB device interface (no reference string) — what xinput1_4 enumerates + opens.
|
// Register the XUSB device interface (no reference string) — what xinput1_4 enumerates + opens.
|
||||||
// SAFETY: device valid; GUID static; null reference string.
|
// SAFETY: `device` is live; the GUID is a static; null reference string.
|
||||||
let st = unsafe {
|
let st = unsafe {
|
||||||
call_unsafe_wdf_function_binding!(
|
call_unsafe_wdf_function_binding!(
|
||||||
WdfDeviceCreateDeviceInterface,
|
WdfDeviceCreateDeviceInterface,
|
||||||
@@ -213,7 +186,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default parallel queue: all the XUSB IOCTLs land here.
|
// Default parallel queue: all the XUSB IOCTLs land here.
|
||||||
// SAFETY: zeroed config then fields set; Size matches the struct.
|
// SAFETY: a zeroed WDF_IO_QUEUE_CONFIG is valid; we then set Size + the fields we use.
|
||||||
let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
|
let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
|
||||||
qcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
|
qcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
|
||||||
qcfg.DispatchType = WdfIoQueueDispatchParallel;
|
qcfg.DispatchType = WdfIoQueueDispatchParallel;
|
||||||
@@ -222,7 +195,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
|
|||||||
qcfg.EvtIoDeviceControl = Some(evt_io_device_control);
|
qcfg.EvtIoDeviceControl = Some(evt_io_device_control);
|
||||||
qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX;
|
qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX;
|
||||||
let mut queue: WDFQUEUE = core::ptr::null_mut();
|
let mut queue: WDFQUEUE = core::ptr::null_mut();
|
||||||
// SAFETY: device + config valid; attributes null; queue receives the handle.
|
// SAFETY: `device` + `qcfg` are valid; attributes null; `queue` receives the handle.
|
||||||
let st = unsafe {
|
let st = unsafe {
|
||||||
call_unsafe_wdf_function_binding!(
|
call_unsafe_wdf_function_binding!(
|
||||||
WdfIoQueueCreate,
|
WdfIoQueueCreate,
|
||||||
@@ -237,93 +210,69 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
|
|||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the host we're alive on the section (its driver-attach health check keys off this).
|
// Run the sealed-channel handshake on a worker (must NOT block EvtDeviceAdd): publish our pid in
|
||||||
touch_driver_marks();
|
// the bootstrap mailbox and poll for the host's delivered DATA handle, so the pad attaches (and
|
||||||
|
// the host's driver-attach health check goes green) even before any game polls XInput. Bounded;
|
||||||
|
// a later host (or a re-delivery) is still picked up by the per-IOCTL pump. This closure is 100%
|
||||||
|
// safe — the whole channel state machine lives in pf_umdf_util.
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
let cfg = channel_cfg();
|
||||||
|
for _ in 0..500 {
|
||||||
|
if let Some(v) = CHANNEL.pump(&cfg) {
|
||||||
|
touch_driver_marks(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
log(
|
||||||
|
"[pf-xusb] no sealed-channel delivery within 10s (host absent, or host/driver version mismatch — see above)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
log("[pf-xusb] device ready (XUSB interface registered)");
|
log("[pf-xusb] device ready (XUSB interface registered)");
|
||||||
STATUS_SUCCESS
|
STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open + map the host's shared section and run `f` against the mapped base if magic is valid, then
|
/// The current controller state from the attached DATA section (zeros / neutral when unattached).
|
||||||
// unmap. Re-mapped per access (the host may recreate the section across restarts).
|
|
||||||
fn with_shm<F: FnOnce(*mut u8)>(f: F) {
|
|
||||||
let name: Vec<u16> = format!("Global\\pfxusb-shm-{}", SHM_INDEX.load(Ordering::Relaxed))
|
|
||||||
.encode_utf16()
|
|
||||||
.chain(std::iter::once(0))
|
|
||||||
.collect();
|
|
||||||
// SAFETY: name is a valid NUL-terminated UTF-16 string.
|
|
||||||
let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, name.as_ptr()) };
|
|
||||||
if h.is_null() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: h is a valid mapping handle; map the whole section; the view keeps it alive.
|
|
||||||
let view = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, SHM_SIZE) } as *mut u8;
|
|
||||||
unsafe { CloseHandle(h) };
|
|
||||||
if view.is_null() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: view points at >= 4 mapped bytes.
|
|
||||||
let magic = unsafe { core::ptr::read_unaligned(view as *const u32) };
|
|
||||||
if magic == SHM_MAGIC {
|
|
||||||
f(view);
|
|
||||||
}
|
|
||||||
// SAFETY: view came from MapViewOfFile.
|
|
||||||
unsafe { UnmapViewOfFile(view as *const c_void) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The current controller state from shared memory (zeros / neutral if the host hasn't connected).
|
|
||||||
/// Returns `(dwPacketNumber, wButtons, lt, rt, lx, ly, rx, ry)`.
|
/// Returns `(dwPacketNumber, wButtons, lt, rt, lx, ly, rx, ry)`.
|
||||||
fn read_state() -> (u32, u16, u8, u8, i16, i16, i16, i16) {
|
fn read_state(data: Option<&MappedView>) -> (u32, u16, u8, u8, i16, i16, i16, i16) {
|
||||||
let mut out = (0u32, 0u16, 0u8, 0u8, 0i16, 0i16, 0i16, 0i16);
|
match data {
|
||||||
with_shm(|v| {
|
Some(v) => (
|
||||||
// SAFETY: v points at a mapped SHM_SIZE section with valid magic.
|
v.read_u32(OFF_PACKET),
|
||||||
unsafe {
|
v.read_u16(OFF_BUTTONS),
|
||||||
out.0 = core::ptr::read_unaligned(v.add(4) as *const u32);
|
v.read_u8(OFF_LT),
|
||||||
out.1 = core::ptr::read_unaligned(v.add(8) as *const u16);
|
v.read_u8(OFF_RT),
|
||||||
out.2 = *v.add(10);
|
v.read_i16(OFF_LX),
|
||||||
out.3 = *v.add(11);
|
v.read_i16(OFF_LY),
|
||||||
out.4 = core::ptr::read_unaligned(v.add(12) as *const i16);
|
v.read_i16(OFF_RX),
|
||||||
out.5 = core::ptr::read_unaligned(v.add(14) as *const i16);
|
v.read_i16(OFF_RY),
|
||||||
out.6 = core::ptr::read_unaligned(v.add(16) as *const i16);
|
),
|
||||||
out.7 = core::ptr::read_unaligned(v.add(18) as *const i16);
|
None => (0, 0, 0, 0, 0, 0, 0, 0),
|
||||||
}
|
}
|
||||||
});
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stamp the driver health marks the host watches: `driver_proto` @32 (the attach signal,
|
/// Stamp the driver health marks the host watches: `driver_proto` (the attach signal, idempotent)
|
||||||
/// idempotent) and `driver_heartbeat` @36 (+1). Called at device add and on every serviced IOCTL,
|
/// and `driver_heartbeat` (+1). Called once the channel attaches and on every serviced IOCTL, so the
|
||||||
/// so the host can tell "driver bound and alive" apart from "driver package missing/failed to
|
/// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see
|
||||||
/// bind" and see the game-visible polling path advance. No-op until the host's section exists
|
/// the game-visible polling path advance.
|
||||||
/// (with_shm re-opens per access, so a section created after we started still gets marked).
|
fn touch_driver_marks(data: &MappedView) {
|
||||||
fn touch_driver_marks() {
|
data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
|
||||||
with_shm(|v| {
|
let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
|
||||||
// SAFETY: v points at a mapped SHM_SIZE section with valid magic; proto @32, heartbeat @36.
|
data.write_u32(OFF_DRIVER_HEARTBEAT, hb);
|
||||||
unsafe {
|
|
||||||
core::ptr::write_unaligned(v.add(32) as *mut u32, GAMEPAD_PROTO_VERSION);
|
|
||||||
let hb = v.add(36) as *mut u32;
|
|
||||||
core::ptr::write_unaligned(hb, core::ptr::read_unaligned(hb).wrapping_add(1));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a game's rumble (from SET_STATE) into shared memory for the host to forward.
|
/// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward.
|
||||||
fn publish_rumble(large: u8, small: u8) {
|
fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) {
|
||||||
with_shm(|v| {
|
let Some(v) = data else { return };
|
||||||
// SAFETY: v points at a mapped SHM_SIZE section; rumble_seq @24, large @28, small @29.
|
v.write_u8(OFF_RUMBLE_LARGE, large);
|
||||||
unsafe {
|
v.write_u8(OFF_RUMBLE_SMALL, small);
|
||||||
*v.add(28) = large;
|
let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1);
|
||||||
*v.add(29) = small;
|
v.write_u32(OFF_RUMBLE_SEQ, seq);
|
||||||
let seqp = v.add(24) as *mut u32;
|
|
||||||
let seq = core::ptr::read_unaligned(seqp).wrapping_add(1);
|
|
||||||
core::ptr::write_unaligned(seqp, seq);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses).
|
// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses).
|
||||||
fn build_get_state() -> [u8; 29] {
|
fn build_get_state(data: Option<&MappedView>) -> [u8; 29] {
|
||||||
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state();
|
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data);
|
||||||
let mut s = [0u8; 29];
|
let mut s = [0u8; 29];
|
||||||
s[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes());
|
s[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes());
|
||||||
s[2] = 0x01; // device count
|
s[2] = 0x01; // device count
|
||||||
@@ -374,11 +323,20 @@ extern "C" fn evt_io_device_control(
|
|||||||
input_len: usize,
|
input_len: usize,
|
||||||
ioctl: ULONG,
|
ioctl: ULONG,
|
||||||
) {
|
) {
|
||||||
// Health marks first: attach signal + heartbeat (also covers a section the host created after
|
// SAFETY: `request` is the live request for THIS EvtIoDeviceControl invocation — exactly the
|
||||||
// this device started — the marks land on the next XInput poll).
|
// contract `Request::new` requires. From here everything is safe (the token owns completion).
|
||||||
touch_driver_marks();
|
let request = unsafe { Request::new(request) };
|
||||||
|
|
||||||
|
// Sealed-channel pump + health marks first: adopt a (late) delivery, detach when the host's
|
||||||
|
// mailbox is gone, and stamp the attach/heartbeat marks the host watches (also covers a host
|
||||||
|
// started after this device — the pump attaches on the next XInput poll).
|
||||||
|
let data = CHANNEL.pump(&channel_cfg());
|
||||||
|
if let Some(v) = data {
|
||||||
|
touch_driver_marks(v);
|
||||||
|
}
|
||||||
|
|
||||||
let status: NTSTATUS = match ioctl {
|
let status: NTSTATUS = match ioctl {
|
||||||
IOCTL_XUSB_GET_INFORMATION => copy_to_output(request, &build_information()),
|
IOCTL_XUSB_GET_INFORMATION => request.copy_to_output(&build_information()),
|
||||||
IOCTL_XUSB_GET_INFORMATION_EX => {
|
IOCTL_XUSB_GET_INFORMATION_EX => {
|
||||||
let mut ex = [0u8; 64];
|
let mut ex = [0u8; 64];
|
||||||
ex[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes());
|
ex[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes());
|
||||||
@@ -387,21 +345,19 @@ extern "C" fn evt_io_device_control(
|
|||||||
ex[8..10].copy_from_slice(&XUSB_VID.to_le_bytes());
|
ex[8..10].copy_from_slice(&XUSB_VID.to_le_bytes());
|
||||||
ex[10..12].copy_from_slice(&XUSB_PID.to_le_bytes());
|
ex[10..12].copy_from_slice(&XUSB_PID.to_le_bytes());
|
||||||
let n = output_len.min(64);
|
let n = output_len.min(64);
|
||||||
copy_to_output(request, &ex[..n])
|
request.copy_to_output(&ex[..n])
|
||||||
}
|
}
|
||||||
IOCTL_XUSB_GET_CAPABILITIES => {
|
IOCTL_XUSB_GET_CAPABILITIES => {
|
||||||
if output_len >= 36 {
|
if output_len >= 36 {
|
||||||
copy_to_output(request, &build_caps_v2())
|
request.copy_to_output(&build_caps_v2())
|
||||||
} else {
|
} else {
|
||||||
copy_to_output(request, &CAPS_V1)
|
request.copy_to_output(&CAPS_V1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IOCTL_XUSB_GET_STATE => copy_to_output(request, &build_get_state()),
|
IOCTL_XUSB_GET_STATE => request.copy_to_output(&build_get_state(data)),
|
||||||
IOCTL_XUSB_GET_LED_STATE => copy_to_output(request, &[0x00, 0x00, 0x06]),
|
IOCTL_XUSB_GET_LED_STATE => request.copy_to_output(&[0x00, 0x00, 0x06]),
|
||||||
IOCTL_XUSB_GET_BATTERY_INFORMATION => {
|
IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]),
|
||||||
copy_to_output(request, &[0x00, 0x01, 0x03, 0x00])
|
IOCTL_XUSB_SET_STATE => on_set_state(&request, data),
|
||||||
}
|
|
||||||
IOCTL_XUSB_SET_STATE => on_set_state(request),
|
|
||||||
IOCTL_XUSB_POWER_DOWN | IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER => STATUS_SUCCESS,
|
IOCTL_XUSB_POWER_DOWN | IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER => STATUS_SUCCESS,
|
||||||
// Decline the async waits → xinput1_4 falls back to synchronous GET_STATE polling.
|
// Decline the async waits → xinput1_4 falls back to synchronous GET_STATE polling.
|
||||||
IOCTL_XUSB_WAIT_GUIDE_BUTTON | IOCTL_XUSB_WAIT_FOR_INPUT => STATUS_INVALID_DEVICE_REQUEST,
|
IOCTL_XUSB_WAIT_GUIDE_BUTTON | IOCTL_XUSB_WAIT_FOR_INPUT => STATUS_INVALID_DEVICE_REQUEST,
|
||||||
@@ -410,30 +366,18 @@ extern "C" fn evt_io_device_control(
|
|||||||
STATUS_INVALID_DEVICE_REQUEST
|
STATUS_INVALID_DEVICE_REQUEST
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// SAFETY: request valid and not forwarded.
|
request.complete(status);
|
||||||
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SET_STATE: the rumble packet. Classic xusb22 layout is small; the motor bytes sit near the end.
|
// SET_STATE: the rumble packet. Classic xusb22 layout is small; the motor bytes sit near the end.
|
||||||
// We publish a best-effort (large = byte 3, small = byte 4 for the 5-byte form) and log the raw bytes
|
// We publish a best-effort (large = byte 2, small = byte 3 for the 5-byte form) and log the raw bytes
|
||||||
// so the exact offsets can be confirmed against a real pad.
|
// so the exact offsets can be confirmed against a real pad.
|
||||||
fn on_set_state(request: WDFREQUEST) -> NTSTATUS {
|
fn on_set_state(request: &Request, data: Option<&MappedView>) -> NTSTATUS {
|
||||||
let mut inmem: WDFMEMORY = core::ptr::null_mut();
|
if let Ok((bytes, len)) = request.input_bytes(8)
|
||||||
// SAFETY: request valid.
|
&& len >= 2
|
||||||
let st = unsafe {
|
{
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem)
|
|
||||||
};
|
|
||||||
if nt_success(st) {
|
|
||||||
let mut len: usize = 0;
|
|
||||||
// SAFETY: inmem valid.
|
|
||||||
let p = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut len) }
|
|
||||||
as *const u8;
|
|
||||||
if !p.is_null() && len >= 2 {
|
|
||||||
let n = len.min(8);
|
|
||||||
// SAFETY: p valid for len bytes; read at most n.
|
|
||||||
let bytes = unsafe { core::slice::from_raw_parts(p, n) };
|
|
||||||
let mut hex = String::new();
|
let mut hex = String::new();
|
||||||
for b in bytes {
|
for b in &bytes {
|
||||||
hex.push_str(&format!("{b:02x} "));
|
hex.push_str(&format!("{b:02x} "));
|
||||||
}
|
}
|
||||||
dbglog!("[pf-xusb] SET_STATE len={len} data: {hex}");
|
dbglog!("[pf-xusb] SET_STATE len={len} data: {hex}");
|
||||||
@@ -441,47 +385,10 @@ fn on_set_state(request: WDFREQUEST) -> NTSTATUS {
|
|||||||
// (large/low-freq at [2], small/high-freq at [3]); 0x01 = player-LED set (ignored).
|
// (large/low-freq at [2], small/high-freq at [3]); 0x01 = player-LED set (ignored).
|
||||||
// 4-byte = raw XINPUT_VIBRATION → the two motor hi bytes.
|
// 4-byte = raw XINPUT_VIBRATION → the two motor hi bytes.
|
||||||
if len >= 5 && bytes[4] == 0x02 {
|
if len >= 5 && bytes[4] == 0x02 {
|
||||||
publish_rumble(bytes[2], bytes[3]);
|
publish_rumble(data, bytes[2], bytes[3]);
|
||||||
} else if len == 4 {
|
} else if len == 4 {
|
||||||
publish_rumble(bytes[1], bytes[3]);
|
publish_rumble(data, bytes[1], bytes[3]);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
STATUS_SUCCESS
|
STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy `src` into the request's (buffered) output buffer and set the completed byte count.
|
|
||||||
fn copy_to_output(request: WDFREQUEST, src: &[u8]) -> NTSTATUS {
|
|
||||||
let mut mem: WDFMEMORY = core::ptr::null_mut();
|
|
||||||
// SAFETY: request valid; mem receives the memory handle.
|
|
||||||
let st = unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut mem)
|
|
||||||
};
|
|
||||||
if !nt_success(st) {
|
|
||||||
return st;
|
|
||||||
}
|
|
||||||
let mut outlen: usize = 0;
|
|
||||||
// SAFETY: mem valid; outlen receives the buffer size.
|
|
||||||
let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) };
|
|
||||||
if outlen < src.len() {
|
|
||||||
return STATUS_INVALID_BUFFER_SIZE;
|
|
||||||
}
|
|
||||||
// SAFETY: mem valid; src is a valid buffer of src.len() bytes.
|
|
||||||
let st = unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(
|
|
||||||
WdfMemoryCopyFromBuffer,
|
|
||||||
mem,
|
|
||||||
0usize,
|
|
||||||
src.as_ptr() as *mut c_void,
|
|
||||||
src.len()
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if !nt_success(st) {
|
|
||||||
return st;
|
|
||||||
}
|
|
||||||
// SAFETY: request valid.
|
|
||||||
unsafe {
|
|
||||||
call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, src.len() as u64)
|
|
||||||
};
|
|
||||||
STATUS_SUCCESS
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
//! code — handled at the call site in STEP 5).
|
//! code — handled at the call site in STEP 5).
|
||||||
#![no_std]
|
#![no_std]
|
||||||
#![allow(non_snake_case, clippy::missing_safety_doc)]
|
#![allow(non_snake_case, clippy::missing_safety_doc)]
|
||||||
// P0 lint (audit §8): require explicit `unsafe {}` blocks inside `unsafe fn`s.
|
// P0 lint (audit §8): require explicit `unsafe {}` blocks inside `unsafe fn`s + a `// SAFETY:` proof on
|
||||||
|
// each (this crate is the IddCx DDI dispatch layer — inherently unsafe, so audited, not unsafe-free).
|
||||||
#![deny(unsafe_op_in_unsafe_fn)]
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
pub use wdk_sys::iddcx;
|
pub use wdk_sys::iddcx;
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ unsafe fn ddi<T: Copy>(index: i32) -> T {
|
|||||||
let table = (&raw const iddcx::IddFunctions).cast::<iddcx::PFN_IDD_CX>();
|
let table = (&raw const iddcx::IddFunctions).cast::<iddcx::PFN_IDD_CX>();
|
||||||
// SAFETY: `index` is a valid IddCx table slot; the slot holds a `PFN_*` whose layout is `T`.
|
// SAFETY: `index` is a valid IddCx table slot; the slot holds a `PFN_*` whose layout is `T`.
|
||||||
let slot = unsafe { table.add(index as usize) };
|
let slot = unsafe { table.add(index as usize) };
|
||||||
|
// SAFETY: `slot` points at the `index`th (in-bounds) populated table entry, a `PFN_*` of layout `T`.
|
||||||
unsafe { slot.cast::<T>().read() }
|
unsafe { slot.cast::<T>().read() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +65,10 @@ macro_rules! iddcx_ddi {
|
|||||||
/// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract.
|
/// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub unsafe fn $name( $( $arg: $aty ),* ) -> NTSTATUS {
|
pub unsafe fn $name( $( $arg: $aty ),* ) -> NTSTATUS {
|
||||||
|
// SAFETY: `$idx`/`$pfn` are the matched IddCx table index + PFN type (pinned by this macro
|
||||||
|
// invocation), and the table is populated once the driver is loaded (this fn's contract).
|
||||||
let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) };
|
let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) };
|
||||||
|
// SAFETY: only reads the stub-provided globals pointer; valid post-load per the contract.
|
||||||
let g = unsafe { globals() };
|
let g = unsafe { globals() };
|
||||||
// SAFETY: dispatching a populated DDI with the stub globals and caller-valid args.
|
// SAFETY: dispatching a populated DDI with the stub globals and caller-valid args.
|
||||||
unsafe { (f.unwrap())(g, $( $arg ),* ) }
|
unsafe { (f.unwrap())(g, $( $arg ),* ) }
|
||||||
@@ -79,7 +85,10 @@ macro_rules! iddcx_ddi {
|
|||||||
/// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract.
|
/// Call only after the driver is loaded by IddCx; pointers must satisfy the IddCx contract.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub unsafe fn $name( $( $arg: $aty ),* ) {
|
pub unsafe fn $name( $( $arg: $aty ),* ) {
|
||||||
|
// SAFETY: `$idx`/`$pfn` are the matched IddCx table index + PFN type (pinned by this macro
|
||||||
|
// invocation), and the table is populated once the driver is loaded (this fn's contract).
|
||||||
let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) };
|
let f: iddcx::$pfn = unsafe { ddi(iddcx::_IDDFUNCENUM::$idx) };
|
||||||
|
// SAFETY: only reads the stub-provided globals pointer; valid post-load per the contract.
|
||||||
let g = unsafe { globals() };
|
let g = unsafe { globals() };
|
||||||
// SAFETY: dispatching a populated DDI with the stub globals and caller-valid args.
|
// SAFETY: dispatching a populated DDI with the stub globals and caller-valid args.
|
||||||
unsafe { (f.unwrap())(g, $( $arg ),* ) }
|
unsafe { (f.unwrap())(g, $( $arg ),* ) }
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Generate the NVENC import library (nvencodeapi.lib) into -OutDir, so the host links with
|
|
||||||
`--features nvenc` on a box that has no NVIDIA Video Codec SDK and no GPU.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
The host links against nvencodeapi.lib (crates/punktfunk-host/build.rs). That import lib is just
|
|
||||||
a link-time stub for two exports of nvEncodeAPI64.dll (the real DLL ships with the NVIDIA driver
|
|
||||||
and resolves at runtime). We synthesise it from nvenc.def:
|
|
||||||
|
|
||||||
1. llvm-dlltool — preferred; LLVM is on the CI runner PATH (C:\Program Files\LLVM\bin) and this
|
|
||||||
works without a Visual Studio developer shell.
|
|
||||||
2. MSVC lib.exe — fallback; located via vswhere (no vcvars needed).
|
|
||||||
|
|
||||||
Point PUNKTFUNK_NVENC_LIB_DIR at -OutDir before `cargo build --features nvenc`.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
pwsh -File gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][string]$OutDir,
|
|
||||||
[string]$DefPath = (Join-Path $PSScriptRoot 'nvenc.def')
|
|
||||||
)
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$ProgressPreference = 'SilentlyContinue'
|
|
||||||
$PSNativeCommandUseErrorActionPreference = $false # check $LASTEXITCODE ourselves (pwsh 7.4 safe)
|
|
||||||
|
|
||||||
if (-not (Test-Path $DefPath)) { throw "module-definition file not found: $DefPath" }
|
|
||||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
|
||||||
$out = Join-Path $OutDir 'nvencodeapi.lib'
|
|
||||||
|
|
||||||
# 1) llvm-dlltool (preferred) ------------------------------------------------------------------
|
|
||||||
$dlltool = Get-Command llvm-dlltool -ErrorAction SilentlyContinue
|
|
||||||
if ($dlltool) {
|
|
||||||
Write-Host "==> llvm-dlltool -> $out"
|
|
||||||
& $dlltool.Source -m i386:x86-64 -d $DefPath -D nvEncodeAPI64.dll -l $out
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "llvm-dlltool failed ($LASTEXITCODE)" }
|
|
||||||
Write-Host " ok ($((Get-Item $out).Length) bytes)"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2) MSVC lib.exe via vswhere (fallback) -------------------------------------------------------
|
|
||||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
|
||||||
if (Test-Path $vswhere) {
|
|
||||||
$lib = & $vswhere -latest -prerelease -products * -find 'VC\Tools\MSVC\**\bin\Hostx64\x64\lib.exe' |
|
|
||||||
Select-Object -First 1
|
|
||||||
if ($lib -and (Test-Path $lib)) {
|
|
||||||
Write-Host "==> lib.exe -> $out"
|
|
||||||
& $lib "/def:$DefPath" /machine:x64 "/out:$out"
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "lib.exe failed ($LASTEXITCODE)" }
|
|
||||||
Write-Host " ok ($((Get-Item $out).Length) bytes)"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw "neither llvm-dlltool (LLVM bin on PATH) nor MSVC lib.exe (via vswhere) was found to build $out"
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
; Module-definition file for the NVENC import library the host links against with `--features nvenc`.
|
|
||||||
;
|
|
||||||
; The real entry points live in nvEncodeAPI64.dll, which ships with the NVIDIA driver. At LINK time
|
|
||||||
; the host only needs an import library exporting these two symbols (see crates/punktfunk-host/build.rs:
|
|
||||||
; it emits `cargo:rustc-link-lib=dylib=nvencodeapi` and searches PUNKTFUNK_NVENC_LIB_DIR). No GPU,
|
|
||||||
; driver, or NVIDIA Video Codec SDK is required to BUILD — only to run, where the DLL resolves from
|
|
||||||
; the installed driver. Generate nvencodeapi.lib from this file with gen-nvenc-importlib.ps1.
|
|
||||||
;
|
|
||||||
; The LIBRARY line names the DLL the import records point at — required for MSVC `lib.exe /def`
|
|
||||||
; (without it the import name would default to "nvenc.dll"). llvm-dlltool takes the name from `-D`.
|
|
||||||
LIBRARY nvEncodeAPI64.dll
|
|
||||||
EXPORTS
|
|
||||||
NvEncodeAPICreateInstance
|
|
||||||
NvEncodeAPIGetMaxSupportedVersion
|
|
||||||
@@ -42,6 +42,8 @@ $here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|||||||
$iss = Join-Path $here 'punktfunk-host.iss'
|
$iss = Join-Path $here 'punktfunk-host.iss'
|
||||||
$exe = Join-Path $TargetDir 'punktfunk-host.exe'
|
$exe = Join-Path $TargetDir 'punktfunk-host.exe'
|
||||||
if (-not (Test-Path $exe)) { throw "missing build artifact 'punktfunk-host.exe' in $TargetDir (did 'cargo build --release -p punktfunk-host --features nvenc' run?)" }
|
if (-not (Test-Path $exe)) { throw "missing build artifact 'punktfunk-host.exe' in $TargetDir (did 'cargo build --release -p punktfunk-host --features nvenc' run?)" }
|
||||||
|
$trayExe = Join-Path $TargetDir 'punktfunk-tray.exe'
|
||||||
|
if (-not (Test-Path $trayExe)) { throw "missing build artifact 'punktfunk-tray.exe' in $TargetDir (did 'cargo build --release -p punktfunk-tray' run?)" }
|
||||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||||
|
|
||||||
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) ---------------------------------------
|
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) ---------------------------------------
|
||||||
@@ -110,14 +112,15 @@ function Sign-File([string]$Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- sign the inner exe before it's packed ----------------------------------------------------
|
# --- sign the inner exes before they're packed -------------------------------------------------
|
||||||
Sign-File $exe
|
Sign-File $exe
|
||||||
|
Sign-File $trayExe
|
||||||
|
|
||||||
# --- resolve + validate the installer's source files ------------------------------------------
|
# --- resolve + validate the installer's source files ------------------------------------------
|
||||||
$repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path
|
$repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path
|
||||||
$hostEnvSrc = Join-Path $repoRoot 'scripts\windows\host.env.example'
|
$hostEnvSrc = Join-Path $repoRoot 'scripts\windows\host.env.example'
|
||||||
$readmeSrc = Join-Path $here 'README.md'
|
$readmeSrc = Join-Path $here 'README.md'
|
||||||
foreach ($p in @($exe, $hostEnvSrc, $readmeSrc, $iss)) {
|
foreach ($p in @($exe, $trayExe, $hostEnvSrc, $readmeSrc, $iss)) {
|
||||||
if (-not (Test-Path -LiteralPath $p)) { throw "installer source file missing: $p" }
|
if (-not (Test-Path -LiteralPath $p)) { throw "installer source file missing: $p" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,12 @@ DefaultGroupName=punktfunk
|
|||||||
DisableProgramGroupPage=yes
|
DisableProgramGroupPage=yes
|
||||||
UsePreviousAppDir=yes
|
UsePreviousAppDir=yes
|
||||||
PrivilegesRequired=admin
|
PrivilegesRequired=admin
|
||||||
MinVersion=10.0
|
; HARD floor: Windows 11 22H2 (build 22621). The pf-vdisplay driver is built against IddCx 1.10
|
||||||
|
; (HDR *2 DDIs + FP16 caps, no runtime downgrade) — on anything older (all of Windows 10 incl.
|
||||||
|
; LTSC, Windows 11 21H2) the driver package installs but the device fails to start with Code 10
|
||||||
|
; STATUS_DEVICE_POWER_FAILURE, and the host can't stream. Gate the install instead; the message
|
||||||
|
; is customized in [Messages] below.
|
||||||
|
MinVersion=10.0.22621
|
||||||
ArchitecturesAllowed=x64
|
ArchitecturesAllowed=x64
|
||||||
ArchitecturesInstallIn64BitMode=x64
|
ArchitecturesInstallIn64BitMode=x64
|
||||||
OutputDir={#OutputDir}
|
OutputDir={#OutputDir}
|
||||||
@@ -113,6 +118,12 @@ UninstallDisplayIcon={app}\punktfunk.ico
|
|||||||
[Languages]
|
[Languages]
|
||||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
|
[Messages]
|
||||||
|
; Shown when MinVersion rejects the OS — name the actual requirement instead of Inno's generic
|
||||||
|
; "requires Windows version 10.0.22621" (users on Windows 10 LTSC hit this; see the pf-vdisplay
|
||||||
|
; IddCx 1.10 note at MinVersion above).
|
||||||
|
WinVersionTooLowError=punktfunk host requires Windows 11 22H2 (build 22621) or newer.%n%nIts virtual display driver needs the IddCx 1.10 framework, which is not available on older Windows — including all editions of Windows 10 (LTSC too) and Windows 11 21H2.
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
#ifdef WithDriver
|
#ifdef WithDriver
|
||||||
Name: "installdriver"; Description: "Install the pf-vdisplay virtual display driver (required for native-resolution streaming)"
|
Name: "installdriver"; Description: "Install the pf-vdisplay virtual display driver (required for native-resolution streaming)"
|
||||||
@@ -134,9 +145,16 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
|
|||||||
; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only).
|
; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only).
|
||||||
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
|
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
|
||||||
Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)"
|
Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)"
|
||||||
|
; The per-user status tray (punktfunk-tray.exe): shows running/stopped/failed at a glance and
|
||||||
|
; offers open-console / start / stop / restart without a terminal. HKLM Run = every user who signs
|
||||||
|
; in to this host box gets one (each session keeps exactly one via a Local\ mutex).
|
||||||
|
Name: "trayicon"; Description: "Show the punktfunk status icon in the notification area at sign-in"
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
; The status tray companion (windows-subsystem, embeds its own icons). Installed unconditionally
|
||||||
|
; (small); only STARTED/registered when the trayicon task is selected.
|
||||||
|
Source: "{#BinDir}\punktfunk-tray.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
Source: "{#HostEnv}"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "{#HostEnv}"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
Source: "{#Readme}"; DestDir: "{app}"; DestName: "README.txt"; Flags: ignoreversion
|
Source: "{#Readme}"; DestDir: "{app}"; DestName: "README.txt"; Flags: ignoreversion
|
||||||
; The branded icon, referenced by UninstallDisplayIcon (Apps & features shows it for the entry).
|
; The branded icon, referenced by UninstallDisplayIcon (Apps & features shows it for the entry).
|
||||||
@@ -184,6 +202,10 @@ Source: "{#VkLayerDir}\pf_vkhdr_layer.json"; DestDir: "{app}\vklayer"; Flags: ig
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
[Registry]
|
[Registry]
|
||||||
|
; Auto-start the status tray at sign-in (all users of this host box; uninsdeletevalue removes it
|
||||||
|
; with the app). Operators who moved --mgmt-bind can append --mgmt-addr/--mgmt-port here.
|
||||||
|
Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
|
||||||
|
ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon
|
||||||
#ifdef WithVkLayer
|
#ifdef WithVkLayer
|
||||||
; Register the HDR Vulkan implicit layer system-wide. The 64-bit Vulkan loader reads
|
; Register the HDR Vulkan implicit layer system-wide. The 64-bit Vulkan loader reads
|
||||||
; HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers; the value NAME is the manifest path and the DWORD
|
; HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers; the value NAME is the manifest path and the DWORD
|
||||||
@@ -222,12 +244,22 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "
|
|||||||
#ifdef WithWeb
|
#ifdef WithWeb
|
||||||
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd
|
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd
|
||||||
; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure),
|
; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure),
|
||||||
; open TCP 3000, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
|
; open TCP 47992, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
|
||||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}"; WorkingDir: "{app}"; \
|
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}"; WorkingDir: "{app}"; \
|
||||||
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
|
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
|
||||||
#endif
|
#endif
|
||||||
|
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
|
||||||
|
; icon appears without waiting for the next sign-in.
|
||||||
|
Filename: "{app}\punktfunk-tray.exe"; Flags: runasoriginaluser nowait skipifsilent; Tasks: trayicon
|
||||||
|
|
||||||
[UninstallRun]
|
[UninstallRun]
|
||||||
|
; Quit the tray FIRST - it is this exe being deleted, so it must not be running. --quit closes the
|
||||||
|
; current session's instance (an elevated caller may message a medium-IL window; UIPI only blocks
|
||||||
|
; low->high); the taskkill then reaps instances in OTHER signed-in sessions. [UninstallRun] runs
|
||||||
|
; before file deletion, so a raced survivor only means a delete-on-reboot leftover, nothing worse.
|
||||||
|
; (runasoriginaluser is not valid in [UninstallRun] - both entries run elevated, which is fine.)
|
||||||
|
Filename: "{app}\punktfunk-tray.exe"; Parameters: "--quit"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkTrayQuit"
|
||||||
|
Filename: "{sys}\taskkill.exe"; Parameters: "/F /IM punktfunk-tray.exe"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkTrayKill"
|
||||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkHostServiceUninstall"
|
Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkHostServiceUninstall"
|
||||||
; Remove the punktfunk drivers we installed (pf-vdisplay devnode + driver package, then the gamepad
|
; Remove the punktfunk drivers we installed (pf-vdisplay devnode + driver package, then the gamepad
|
||||||
; driver packages). AFTER service uninstall so the host no longer holds the devices. Unconditional
|
; driver packages). AFTER service uninstall so the host no longer holds the devices. Unconditional
|
||||||
@@ -241,7 +273,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall --gamepad";
|
|||||||
; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config,
|
; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config,
|
||||||
; like the host uninstall does).
|
; like the host uninstall does).
|
||||||
Filename: "powershell.exe"; \
|
Filename: "powershell.exe"; \
|
||||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
|
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 47992,3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
|
||||||
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
|
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -300,7 +332,7 @@ begin
|
|||||||
FreshWebInstall := not FileExists(WebPasswordPath);
|
FreshWebInstall := not FileExists(WebPasswordPath);
|
||||||
WebPwPage := CreateInputQueryPage(wpSelectTasks,
|
WebPwPage := CreateInputQueryPage(wpSelectTasks,
|
||||||
'Web console', 'Set the punktfunk web console login password',
|
'Web console', 'Set the punktfunk web console login password',
|
||||||
'The management console is served on http://this-computer:3000 and is login-gated. Keep the ' +
|
'The management console is served on https://this-computer:47992 and is login-gated. Keep the ' +
|
||||||
'secure password generated below (it is shown again on the final page) or enter your own - you ' +
|
'secure password generated below (it is shown again on the final page) or enter your own - you ' +
|
||||||
'can change it later in %ProgramData%\punktfunk\web-password.');
|
'can change it later in %ProgramData%\punktfunk\web-password.');
|
||||||
WebPwPage.Add('Console password:', False); { visible, so the admin can read the generated default }
|
WebPwPage.Add('Console password:', False); { visible, so the admin can read the generated default }
|
||||||
@@ -329,7 +361,7 @@ procedure CurPageChanged(CurPageID: Integer);
|
|||||||
begin
|
begin
|
||||||
if (CurPageID = wpFinished) and FreshWebInstall then
|
if (CurPageID = wpFinished) and FreshWebInstall then
|
||||||
WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 +
|
WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 +
|
||||||
'Web console: http://<this-PC-IP>:3000' + #13#10 +
|
'Web console: https://<this-PC-IP>:47992' + #13#10 +
|
||||||
'Login password: ' + Trim(WebPwPage.Values[0]);
|
'Login password: ' + Trim(WebPwPage.Values[0]);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -344,6 +376,17 @@ begin
|
|||||||
end;
|
end;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
{ On upgrade a running tray locks punktfunk-tray.exe - kill every session's instance so the copy
|
||||||
|
can overwrite it (the [Run] entry / next sign-in relaunches the new build). Best-effort; a fresh
|
||||||
|
install is a no-op. }
|
||||||
|
procedure StopTrays;
|
||||||
|
var
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM punktfunk-tray.exe', '',
|
||||||
|
SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
end;
|
||||||
|
|
||||||
{ On upgrade the running service locks punktfunk-host.exe (and the supervisor would respawn it from
|
{ On upgrade the running service locks punktfunk-host.exe (and the supervisor would respawn it from
|
||||||
the OLD binary), so stop it and WAIT for STOPPED before files are copied. Best-effort; a fresh
|
the OLD binary), so stop it and WAIT for STOPPED before files are copied. Best-effort; a fresh
|
||||||
install is a no-op (the service doesn't exist yet). }
|
install is a no-op (the service doesn't exist yet). }
|
||||||
@@ -361,10 +404,11 @@ begin
|
|||||||
end;
|
end;
|
||||||
|
|
||||||
#ifdef WithWeb
|
#ifdef WithWeb
|
||||||
{ Stop a running web console + free :3000 BEFORE the file copy, so the old server doesn't lock
|
{ Stop a running web console + free its port BEFORE the file copy, so the old server doesn't lock
|
||||||
.output / web-run.cmd / bun.exe and the new task can bind. Killing the :3000 listener owner is
|
.output / web-run.cmd / bun.exe and the new task can bind. Killing the listener owner is
|
||||||
runtime-agnostic (an early install may have run node, the current one runs bun). `web setup`
|
runtime-agnostic (an early install may have run node on :3000, the current one runs bun on
|
||||||
repeats this idempotently after the copy. Best-effort; a fresh install is a no-op. }
|
:47992 - sweep both). `web setup` repeats this idempotently after the copy. Best-effort; a
|
||||||
|
fresh install is a no-op. }
|
||||||
procedure StopWebConsole;
|
procedure StopWebConsole;
|
||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
@@ -373,7 +417,7 @@ begin
|
|||||||
'-NoProfile -ExecutionPolicy Bypass -Command "' +
|
'-NoProfile -ExecutionPolicy Bypass -Command "' +
|
||||||
'$ErrorActionPreference=''SilentlyContinue''; ' +
|
'$ErrorActionPreference=''SilentlyContinue''; ' +
|
||||||
'Stop-ScheduledTask -TaskName PunktfunkWeb; ' +
|
'Stop-ScheduledTask -TaskName PunktfunkWeb; ' +
|
||||||
'Get-NetTCPConnection -LocalPort 3000 -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }"',
|
'Get-NetTCPConnection -LocalPort 47992,3000 -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }"',
|
||||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
end;
|
end;
|
||||||
#endif
|
#endif
|
||||||
@@ -383,6 +427,7 @@ begin
|
|||||||
if CurStep = ssInstall then
|
if CurStep = ssInstall then
|
||||||
begin
|
begin
|
||||||
StopHostServiceAndWait;
|
StopHostServiceAndWait;
|
||||||
|
StopTrays; { upgrade-safe: unlock punktfunk-tray.exe before the copy }
|
||||||
#ifdef WithWeb
|
#ifdef WithWeb
|
||||||
StopWebConsole; { upgrade-safe: free :3000 + unlock the web files before the copy }
|
StopWebConsole; { upgrade-safe: free :3000 + unlock the web files before the copy }
|
||||||
{ Stash the chosen password for `web setup` (fresh install only); the temp copy is auto-cleaned. }
|
{ Stash the chosen password for `web setup` (fresh install only); the temp copy is auto-cleaned. }
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the punktfunk-tray status icons (committed, like the other branding assets).
|
||||||
|
|
||||||
|
Renders the brand mark — the two overlapping circles ("lens") from web's brand-mark.tsx, the
|
||||||
|
same geometry gen-branding.ps1 uses — with a status dot in the lower-right corner:
|
||||||
|
|
||||||
|
running colored mark + green dot
|
||||||
|
stopped grayscale mark + gray dot
|
||||||
|
error colored mark + red dot
|
||||||
|
degraded colored mark + amber dot (starting / running-but-status-unreachable)
|
||||||
|
streaming colored mark + bright-violet dot
|
||||||
|
|
||||||
|
Outputs (all checked in; re-run only when the brand or the palette changes):
|
||||||
|
packaging/windows/branding/punktfunk-tray-<state>.ico 16/20/24/32/48 px PNG-entry icos
|
||||||
|
(Vista+ format, same as punktfunk.ico)
|
||||||
|
packaging/linux/icons/hicolor/{22x22,48x48}/apps/punktfunk-tray[-<state>].png
|
||||||
|
(running is the unsuffixed base name)
|
||||||
|
|
||||||
|
Pure stdlib (zlib PNG writer, analytic 4x-supersampled rasterizer) so it runs on any dev box —
|
||||||
|
no PIL/ImageMagick/librsvg needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Brand-mark geometry in its 1000-unit viewbox (brand-mark.tsx; mirrors gen-branding.ps1).
|
||||||
|
R = 194.41
|
||||||
|
C1 = (403.037, 597.262) # light circle, behind
|
||||||
|
C2 = (597.8075, 402.8525) # deep circle, in front
|
||||||
|
BB_MIN = (C1[0] - R, C2[1] - R)
|
||||||
|
BB_MAX = (C2[0] + R, C1[1] + R)
|
||||||
|
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
|
||||||
|
MARK_SPAN = BB_MAX[0] - BB_MIN[0] # the bbox is square
|
||||||
|
|
||||||
|
COL_LIGHT = (0xA7, 0x9F, 0xF8)
|
||||||
|
COL_DEEP = (0x6C, 0x5B, 0xF3)
|
||||||
|
COL_HI = (0xD2, 0xC9, 0xFB)
|
||||||
|
RING = (0x1C, 0x15, 0x30) # dot outline, the brand tile background
|
||||||
|
|
||||||
|
STATES = {
|
||||||
|
"running": {"dot": (0x2E, 0xCC, 0x71), "gray": False},
|
||||||
|
"stopped": {"dot": (0x8A, 0x8A, 0x8A), "gray": True},
|
||||||
|
"error": {"dot": (0xE7, 0x4C, 0x3C), "gray": False},
|
||||||
|
"degraded": {"dot": (0xF0, 0xA0, 0x30), "gray": False},
|
||||||
|
"streaming": {"dot": (0xB4, 0x4C, 0xF0), "gray": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def luma(c):
|
||||||
|
y = round(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2])
|
||||||
|
return (y, y, y)
|
||||||
|
|
||||||
|
|
||||||
|
def render(size, dot_rgb, gray, ss=4):
|
||||||
|
"""RGBA rows, 4x supersampled: mark centered upper-left-ish, dot lower-right."""
|
||||||
|
n = size * ss
|
||||||
|
mark_c = (0.44 * n, 0.44 * n)
|
||||||
|
scale = (0.82 * n) / MARK_SPAN
|
||||||
|
dot_c = (0.76 * n, 0.76 * n)
|
||||||
|
dot_r = 0.21 * n
|
||||||
|
ring_r = dot_r + max(0.055 * n, 1.0 * ss)
|
||||||
|
c_light = luma(COL_LIGHT) if gray else COL_LIGHT
|
||||||
|
c_deep = luma(COL_DEEP) if gray else COL_DEEP
|
||||||
|
c_hi = luma(COL_HI) if gray else COL_HI
|
||||||
|
c1 = (mark_c[0] + (C1[0] - MARK_CENTER[0]) * scale, mark_c[1] + (C1[1] - MARK_CENTER[1]) * scale)
|
||||||
|
c2 = (mark_c[0] + (C2[0] - MARK_CENTER[0]) * scale, mark_c[1] + (C2[1] - MARK_CENTER[1]) * scale)
|
||||||
|
r = R * scale
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for y in range(size):
|
||||||
|
row = bytearray()
|
||||||
|
for x in range(size):
|
||||||
|
# Premultiplied accumulation over the ss×ss sample grid (no fringe on the rim).
|
||||||
|
ar = ag = ab = aa = 0.0
|
||||||
|
for sy in range(ss):
|
||||||
|
for sx in range(ss):
|
||||||
|
px = x * ss + sx + 0.5
|
||||||
|
py = y * ss + sy + 0.5
|
||||||
|
d1 = math.hypot(px - c1[0], py - c1[1])
|
||||||
|
d2 = math.hypot(px - c2[0], py - c2[1])
|
||||||
|
dd = math.hypot(px - dot_c[0], py - dot_c[1])
|
||||||
|
col = None
|
||||||
|
if dd < dot_r:
|
||||||
|
col = dot_rgb
|
||||||
|
elif dd < ring_r:
|
||||||
|
col = RING
|
||||||
|
elif d1 < r and d2 < r:
|
||||||
|
col = c_hi
|
||||||
|
elif d2 < r:
|
||||||
|
col = c_deep
|
||||||
|
elif d1 < r:
|
||||||
|
col = c_light
|
||||||
|
if col is not None:
|
||||||
|
ar += col[0]
|
||||||
|
ag += col[1]
|
||||||
|
ab += col[2]
|
||||||
|
aa += 255.0
|
||||||
|
samples = ss * ss
|
||||||
|
a = aa / samples
|
||||||
|
if a < 1.0:
|
||||||
|
row += b"\x00\x00\x00\x00"
|
||||||
|
else:
|
||||||
|
row += bytes(
|
||||||
|
(round(ar / aa * 255), round(ag / aa * 255), round(ab / aa * 255), round(a))
|
||||||
|
)
|
||||||
|
rows.append(bytes(row))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def png_bytes(size, rows):
|
||||||
|
def chunk(tag, data):
|
||||||
|
return (
|
||||||
|
struct.pack(">I", len(data))
|
||||||
|
+ tag
|
||||||
|
+ data
|
||||||
|
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
|
||||||
|
ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
|
||||||
|
idat = zlib.compress(b"".join(b"\x00" + r for r in rows), 9)
|
||||||
|
return (
|
||||||
|
b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ico_bytes(pngs):
|
||||||
|
"""PNG-entry .ico (Vista+; the format punktfunk.ico already uses)."""
|
||||||
|
header = struct.pack("<HHH", 0, 1, len(pngs))
|
||||||
|
entries = b""
|
||||||
|
blobs = b""
|
||||||
|
offset = len(header) + 16 * len(pngs)
|
||||||
|
for size, png in pngs:
|
||||||
|
entries += struct.pack(
|
||||||
|
"<BBBBHHII", size if size < 256 else 0, size if size < 256 else 0, 0, 0, 1, 32, len(png), offset
|
||||||
|
)
|
||||||
|
blobs += png
|
||||||
|
offset += len(png)
|
||||||
|
return header + entries + blobs
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ico_dir = REPO / "packaging/windows/branding"
|
||||||
|
for state, spec in STATES.items():
|
||||||
|
pngs = [
|
||||||
|
(s, png_bytes(s, render(s, spec["dot"], spec["gray"])))
|
||||||
|
for s in (16, 20, 24, 32, 48)
|
||||||
|
]
|
||||||
|
out = ico_dir / f"punktfunk-tray-{state}.ico"
|
||||||
|
out.write_bytes(ico_bytes(pngs))
|
||||||
|
print(f"wrote {out.relative_to(REPO)}")
|
||||||
|
|
||||||
|
for s in (22, 48):
|
||||||
|
name = "punktfunk-tray" if state == "running" else f"punktfunk-tray-{state}"
|
||||||
|
png_dir = REPO / f"packaging/linux/icons/hicolor/{s}x{s}/apps"
|
||||||
|
png_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out = png_dir / f"{name}.png"
|
||||||
|
out.write_bytes(png_bytes(s, render(s, spec["dot"], spec["gray"])))
|
||||||
|
print(f"wrote {out.relative_to(REPO)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -9,11 +9,11 @@ Helper scripts for the Windows host box (the RTX `.173` lab box, repo at
|
|||||||
powershell -ExecutionPolicy Bypass -File scripts\windows\setup-build-env.ps1
|
powershell -ExecutionPolicy Bypass -File scripts\windows\setup-build-env.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
Persists (Machine scope) the three vars the NVENC build needs:
|
Persists (Machine scope) the vars the host build needs (NVENC itself needs none — its entry
|
||||||
|
points are runtime-loaded from the driver's `nvEncodeAPI64.dll`):
|
||||||
|
|
||||||
| var | value | why |
|
| var | value | why |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `PUNKTFUNK_NVENC_LIB_DIR` | `C:\Users\Public\nvenc` | NVENC import lib (`nvencodeapi.lib`) |
|
|
||||||
| `LIBCLANG_PATH` | `C:\Program Files\LLVM\bin` | bindgen (`libclang.dll`) |
|
| `LIBCLANG_PATH` | `C:\Program Files\LLVM\bin` | bindgen (`libclang.dll`) |
|
||||||
| `CMAKE_POLICY_VERSION_MINIMUM` | `3.5` | `audiopus_sys` / cmake crates |
|
| `CMAKE_POLICY_VERSION_MINIMUM` | `3.5` | `audiopus_sys` / cmake crates |
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Set-Location $repo
|
|||||||
|
|
||||||
# Load the persisted build env (Machine scope) into THIS process, so the build sees it even
|
# Load the persisted build env (Machine scope) into THIS process, so the build sees it even
|
||||||
# if this shell was started before setup-build-env.ps1 ran (env is inherited at spawn time).
|
# if this shell was started before setup-build-env.ps1 ran (env is inherited at spawn time).
|
||||||
foreach ($k in 'PUNKTFUNK_NVENC_LIB_DIR','LIBCLANG_PATH','CMAKE_POLICY_VERSION_MINIMUM') {
|
foreach ($k in 'LIBCLANG_PATH','CMAKE_POLICY_VERSION_MINIMUM') {
|
||||||
$v = [Environment]::GetEnvironmentVariable($k, 'Machine')
|
$v = [Environment]::GetEnvironmentVariable($k, 'Machine')
|
||||||
if ($v) { [Environment]::SetEnvironmentVariable($k, $v, 'Process'); Write-Host "env : $k=$v" }
|
if ($v) { [Environment]::SetEnvironmentVariable($k, $v, 'Process'); Write-Host "env : $k=$v" }
|
||||||
else { Write-Warning "env $k not set (run setup-build-env.ps1)" }
|
else { Write-Warning "env $k not set (run setup-build-env.ps1)" }
|
||||||
|
|||||||