Compare commits
162 Commits
e919fa6a2e
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a2e07e865 | |||
| 6e949b6748 | |||
| 8ae161fe61 | |||
| 3a89ee8cd7 | |||
| dac0fee4e3 | |||
| 125a51d81d | |||
| 7b99b41ede | |||
| 9ea2c17419 | |||
| a9cca82fb8 | |||
| 7ab0661ddc | |||
| 92e68024f1 | |||
| 64abce6daa | |||
| bdfab8e0d5 | |||
| 8e87e617df | |||
| 5bf787eb2b | |||
| 0a6c9d8852 | |||
| 0eedfb3c1f | |||
| f6490f4c28 | |||
| d01a8fd17a | |||
| 3e7c9bd059 | |||
| 7aa787a789 | |||
| 3514702d8c | |||
| 327a5fa828 | |||
| 9777ed7fb3 | |||
| ba68a98873 | |||
| 22359f5dc8 | |||
| 7e9023faad | |||
| 5acc12d9e9 | |||
| aed0bf0c2a | |||
| b65745284e | |||
| 8ca695eb4c | |||
| 61c02e695e | |||
| 203ad8069d | |||
| 5f8c6b6147 | |||
| cd3368fc71 | |||
| bd05bc8c30 | |||
| 658564353c | |||
| 6b3cbce120 | |||
| 739fa74e68 | |||
| c87ca577a3 | |||
| e68b7330ae | |||
| e5c2b4e7f5 | |||
| 7ad3a57e68 | |||
| 22bef1fd0a | |||
| bf577044f1 | |||
| 4c95ba72a3 | |||
| 011607ec10 | |||
| 803573b4ec | |||
| 00cf51d610 | |||
| 84a3b95f17 | |||
| 8cde8621ce | |||
| 0bf3984614 | |||
| 75ee53d1dd | |||
| 0255a8289c | |||
| 6bed5d9e8e | |||
| 48202a0f89 | |||
| bf57aa4000 | |||
| 0ccd0fe676 | |||
| e1ca2e4d3c | |||
| e119aa50e9 | |||
| 683c81be03 | |||
| fe61597d92 | |||
| d9b8b88a42 | |||
| 15202011c1 | |||
| 05e87e6ab0 | |||
| 38c68c33e5 | |||
| a0427cd2a3 | |||
| a4c85af155 | |||
| 9ba90d4b77 | |||
| 5358ef9fee | |||
| 0a63154293 | |||
| e5057f6cc1 | |||
| a3eefc2374 | |||
| cd591514ad | |||
| a2bd0cd77c | |||
| 48f980ebb1 | |||
| 1cd87066d7 | |||
| 789ad49bc4 | |||
| c87bfe0e7b | |||
| f98ab07dd6 | |||
| dbab1f98ba | |||
| 5d279f8886 | |||
| e60cda3939 | |||
| d638a93e04 | |||
| a755d6eab7 | |||
| b0d28380b5 | |||
| ed583650a6 | |||
| e5c9ee8327 | |||
| 0a7ae5ef09 | |||
| 95dcef3515 | |||
| 0badc17d87 | |||
| a11b0dd3c7 | |||
| 3b21d8ecf8 | |||
| 83d3d6384a | |||
| 6399d2817d | |||
| e2f004589c | |||
| 590ceaa850 | |||
| d8a453f6ca | |||
| 024e709191 | |||
| 94e82df9f3 | |||
| bbc891e50a | |||
| 3e535f1de4 | |||
| c94a81d523 | |||
| df32060655 | |||
| 55899bf73f | |||
| 725e596d2b | |||
| d17aeefd1c | |||
| 1b0a13c25e | |||
| 3d3dd3627c | |||
| ad27174027 | |||
| d0d31b1040 | |||
| 4f10f3439d | |||
| 788e4acbb5 | |||
| d7a9fbf0b6 | |||
| f652617f30 | |||
| ae803b24d5 | |||
| 3fbabc854c | |||
| 8c4e7b07bf | |||
| 6d8c7a5185 | |||
| 2f7847ce9b | |||
| c6a818e985 | |||
| f34e956818 | |||
| 04e52b0c22 | |||
| 2df3c0f2b4 | |||
| 60df3c9c52 | |||
| 9fd19b90a9 | |||
| 6975691f7d | |||
| f896f70bb8 | |||
| b24c10a723 | |||
| 1682b83b3f | |||
| 838cac4f69 | |||
| 4f62643c82 | |||
| c91e7a0e38 | |||
| bed4711096 | |||
| 5d3cb5e63f | |||
| d3e4ea0118 | |||
| 43144203fa | |||
| d8a7d6f3a2 | |||
| 8a04db9844 | |||
| 0b663cefb6 | |||
| e2c9bfd3d9 | |||
| c5dab484df | |||
| e27abc065e | |||
| d39da4bc06 | |||
| 095540efc2 | |||
| de232ec2f7 | |||
| e4e34fdb48 | |||
| 3ec462c2ea | |||
| 58f4dccc02 | |||
| 32879f45bf | |||
| b54f781524 | |||
| 5e106c51cf | |||
| d2746bd65a | |||
| 9b840151e4 | |||
| a12c6e0ba4 | |||
| b0c82333d2 | |||
| f208f3d92e | |||
| 51de8ccbdb | |||
| 118752c136 | |||
| 9af8e9a7d9 | |||
| e466814ef8 | |||
| 95c6ceb072 |
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
# Root build context is used only by web/Dockerfile, which needs web/ and
|
# Root build context is used only by web/Dockerfile, which needs web/ and
|
||||||
# docs/api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
||||||
# out of the context upload.
|
# out of the context upload.
|
||||||
*
|
*
|
||||||
!web
|
!web
|
||||||
!docs/api/openapi.json
|
!api/openapi.json
|
||||||
web/node_modules
|
web/node_modules
|
||||||
web/.output
|
web/.output
|
||||||
web/dist
|
web/dist
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
|
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
|
||||||
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
|
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
|
||||||
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
|
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
|
||||||
|
#
|
||||||
|
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
|
||||||
|
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
|
||||||
|
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
|
||||||
|
# from the build/test job and best-effort, so a capture gap never reds the core signal.
|
||||||
name: apple
|
name: apple
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -37,3 +42,55 @@ jobs:
|
|||||||
- name: Test (unit + real-codec round trip; remote tests self-skip)
|
- name: Test (unit + real-codec round trip; remote tests self-skip)
|
||||||
working-directory: clients/apple
|
working-directory: clients/apple
|
||||||
run: swift test
|
run: swift test
|
||||||
|
|
||||||
|
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
|
||||||
|
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||||
|
# first, and is a separate job so a capture hiccup can never red the core signal.
|
||||||
|
#
|
||||||
|
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
|
||||||
|
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
|
||||||
|
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
|
||||||
|
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
|
||||||
|
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
|
||||||
|
screenshots:
|
||||||
|
needs: swift
|
||||||
|
if: gitea.event_name != 'pull_request'
|
||||||
|
runs-on: macos-arm64
|
||||||
|
timeout-minutes: 75
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Rust toolchain + iOS Simulator targets
|
||||||
|
run: |
|
||||||
|
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --no-modify-path --profile minimal
|
||||||
|
fi
|
||||||
|
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||||
|
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||||
|
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||||
|
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||||
|
|
||||||
|
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||||
|
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||||
|
|
||||||
|
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
|
||||||
|
working-directory: clients/apple
|
||||||
|
env:
|
||||||
|
SETTLE: "8" # Simulators settle slower than a local run
|
||||||
|
run: |
|
||||||
|
# Independent invocations: one platform failing skips it, not the other.
|
||||||
|
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
|
||||||
|
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
|
||||||
|
echo "Produced:"; ls -la screenshots || true
|
||||||
|
|
||||||
|
- name: Upload screenshots (zip artifact)
|
||||||
|
if: always()
|
||||||
|
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
|
||||||
|
# (upload-artifact@v4) refuses. v3 uses the older API Gitea supports; download is still a zip.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: punktfunk-appstore-screenshots
|
||||||
|
path: clients/apple/screenshots
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 30
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
|
||||||
# docs/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
|
||||||
paths:
|
paths:
|
||||||
- 'clients/linux/**'
|
- 'clients/linux/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ name: release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# Canary: a relevant main push uploads the iOS + macOS builds to TestFlight (Apple's own
|
# Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's
|
||||||
# canary channel) — no notarized DMG, no tvOS (those are stable-only; see the per-step gates).
|
# own canary channel) — no notarized DMG (that's stable-only; see the per-step gates).
|
||||||
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
|
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
|
||||||
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
|
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -118,16 +118,11 @@ jobs:
|
|||||||
"$RUSTUP" toolchain install nightly --profile minimal
|
"$RUSTUP" toolchain install nightly --profile minimal
|
||||||
"$RUSTUP" component add rust-src --toolchain nightly
|
"$RUSTUP" component add rust-src --toolchain nightly
|
||||||
|
|
||||||
- name: Build PunktfunkCore.xcframework (mac + iOS; + tvOS on stable tags)
|
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||||
# tvOS uses nightly -Zbuild-std (slow) — build it only for a real release, not on every
|
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||||
# canary main push.
|
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||||
run: |
|
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
|
||||||
TV=""
|
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||||
case "$GITHUB_REF" in refs/tags/v*) TV="BUILD_TVOS=1" ;; esac
|
|
||||||
# `env` (not a bare prefix): a $TV-expanded `NAME=val` word is NOT re-promoted to a shell
|
|
||||||
# assignment, so `BUILD_IOS=1 $TV bash …` would try to RUN `BUILD_TVOS=1` (exit 127). env
|
|
||||||
# treats its leading NAME=val args as assignments post-expansion; empty $TV is a no-op.
|
|
||||||
env BUILD_IOS=1 $TV bash scripts/build-xcframework.sh
|
|
||||||
|
|
||||||
- name: Stage App Store Connect API key
|
- name: Stage App Store Connect API key
|
||||||
env:
|
env:
|
||||||
@@ -293,9 +288,9 @@ jobs:
|
|||||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||||
|
|
||||||
- name: tvOS — archive + upload to TestFlight
|
- name: tvOS — archive + upload to TestFlight
|
||||||
# Stable only — the tvOS xcframework slice is built just for releases (above), and the
|
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
||||||
# App Store Connect record + runner platform are tvOS prerequisites.
|
# on every apple push (above), so this matches the iOS step's gate exactly.
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v') && (gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true')
|
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||||
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
||||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# One-shot provisioning of the WDK + cargo-wdk onto the persistent self-hosted windows-amd64 runner, so
|
||||||
|
# the all-Rust UMDF drivers can build there (design/windows-host-rewrite.md, M0). The runner has the base
|
||||||
|
# Windows SDK + MSVC + LLVM + Rust but NOT the WDK (no km/wdf/iddcx headers) or cargo-wdk.
|
||||||
|
#
|
||||||
|
# Dispatch manually (workflow_dispatch). Idempotent: re-running is a near no-op once provisioned. The
|
||||||
|
# install persists on the runner (real box, not an ephemeral container), so this runs once, not per build.
|
||||||
|
name: windows-drivers-provision
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'scripts/ci/provision-windows-wdk.ps1'
|
||||||
|
- '.gitea/workflows/windows-drivers-provision.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
provision:
|
||||||
|
runs-on: windows-amd64
|
||||||
|
timeout-minutes: 60
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install WDK + cargo-wdk on the runner
|
||||||
|
run: ./scripts/ci/provision-windows-wdk.ps1
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Windows driver workspace CI — runs on the self-hosted Windows runner (home-windows-1, host mode;
|
||||||
|
# label windows-amd64). Part of the Windows-host rewrite (design/windows-host-rewrite.md, M0).
|
||||||
|
#
|
||||||
|
# Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the
|
||||||
|
# inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code,
|
||||||
|
# and build+test the owned ABI crate (pf-driver-proto) on MSVC to prove it compiles cross-OS and the
|
||||||
|
# CI wiring works. The runner has no RTX GPU — that's fine: builds, the IddCx bindgen/link, the
|
||||||
|
# /INTEGRITYCHECK self-sign-load, and (later) IDD-push frame flow on the basic display do not need one;
|
||||||
|
# only live NVENC encode does, which defers to the RTX box.
|
||||||
|
#
|
||||||
|
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
|
||||||
|
# first GITHUB_ENV line — see windows.yml).
|
||||||
|
name: windows-drivers
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- '.gitea/workflows/windows-drivers.yml'
|
||||||
|
- 'crates/pf-driver-proto/**'
|
||||||
|
- 'packaging/windows/drivers/**'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.gitea/workflows/windows-drivers.yml'
|
||||||
|
- 'crates/pf-driver-proto/**'
|
||||||
|
- 'packaging/windows/drivers/**'
|
||||||
|
|
||||||
|
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml).
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
probe-and-proto:
|
||||||
|
runs-on: windows-amd64
|
||||||
|
timeout-minutes: 30
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Probe driver toolchain (informational — never fails the job)
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
function head($t) { Write-Host ""; Write-Host "===== $t =====" }
|
||||||
|
|
||||||
|
head "Windows Kits roots"
|
||||||
|
$kits = @('C:\Program Files (x86)\Windows Kits\10', 'C:\Program Files\Windows Kits\10')
|
||||||
|
foreach ($k in $kits) { if (Test-Path $k) { Write-Host "found: $k" } }
|
||||||
|
|
||||||
|
head "SDK Include versions (um vs km — km => WDK present)"
|
||||||
|
foreach ($k in $kits) {
|
||||||
|
$inc = Join-Path $k 'Include'
|
||||||
|
if (Test-Path $inc) {
|
||||||
|
Get-ChildItem $inc -Directory | ForEach-Object {
|
||||||
|
$hasUm = Test-Path (Join-Path $_.FullName 'um')
|
||||||
|
$hasKm = Test-Path (Join-Path $_.FullName 'km')
|
||||||
|
$wdf = Test-Path (Join-Path $_.FullName 'km\wdf\umdf\2.31')
|
||||||
|
$iddcx = (Get-ChildItem (Join-Path $_.FullName 'um\iddcx') -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) -join ','
|
||||||
|
Write-Host ("{0,-16} um={1,-5} km={2,-5} wdf2.31={3,-5} iddcx=[{4}]" -f $_.Name, $hasUm, $hasKm, $wdf, $iddcx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
head "Driver tooling (inf2cat / stampinf / signtool / devgen / InfVerif)"
|
||||||
|
foreach ($tool in 'inf2cat.exe','stampinf.exe','signtool.exe','devgen.exe','InfVerif.exe','makecat.exe') {
|
||||||
|
$hits = @()
|
||||||
|
foreach ($k in $kits) {
|
||||||
|
$hits += Get-ChildItem -Path $k -Filter $tool -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 -ExpandProperty FullName
|
||||||
|
}
|
||||||
|
$hits = $hits | Where-Object { $_ } | Select-Object -First 1
|
||||||
|
Write-Host ("{0,-14} -> {1}" -f $tool, ($(if ($hits) { $hits } else { 'NOT FOUND' })))
|
||||||
|
}
|
||||||
|
|
||||||
|
head "EWDK"
|
||||||
|
Write-Host ("EWDKROOT = " + ($env:EWDKROOT ?? '<unset>'))
|
||||||
|
|
||||||
|
head "LLVM / clang (bindgen 0.72 builds on the runner default clang)"
|
||||||
|
Write-Host ("LIBCLANG_PATH = " + ($env:LIBCLANG_PATH ?? '<unset>'))
|
||||||
|
$clang = Get-Command clang -ErrorAction SilentlyContinue
|
||||||
|
if ($clang) { & clang --version } else { Write-Host "clang: NOT on PATH" }
|
||||||
|
|
||||||
|
head "cargo-make (the gamepad drivers' build driver)"
|
||||||
|
$cm = & cargo make --version 2>&1; Write-Host $cm
|
||||||
|
|
||||||
|
head "Rust + targets"
|
||||||
|
& rustc -V; & cargo -V
|
||||||
|
Write-Host "installed targets:"; & rustup target list --installed
|
||||||
|
|
||||||
|
head "Env knobs the WDK build cares about"
|
||||||
|
Write-Host ("Version_Number = " + ($env:Version_Number ?? '<unset>'))
|
||||||
|
Write-Host ("CARGO_HOME = " + ($env:CARGO_HOME ?? '<unset>'))
|
||||||
|
Write-Host ("CARGO_TARGET_DIR (daemon) = " + ($env:CARGO_TARGET_DIR ?? '<unset>'))
|
||||||
|
|
||||||
|
- name: Build + test pf-driver-proto (MSVC)
|
||||||
|
run: |
|
||||||
|
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
|
||||||
|
$env:CARGO_TARGET_DIR = "C:\t\drv"
|
||||||
|
cargo build -p pf-driver-proto
|
||||||
|
cargo test -p pf-driver-proto
|
||||||
|
cargo clippy -p pf-driver-proto --all-targets -- -D warnings
|
||||||
|
cargo fmt -p pf-driver-proto -- --check
|
||||||
|
|
||||||
|
# Build the UMDF driver workspace (wdk-probe) on windows-drivers-rs: proves wdk-sys bindgen/link works
|
||||||
|
# on the runner's WDK + LLVM, that pf-driver-proto path-deps into a driver, and exposes the produced
|
||||||
|
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
|
||||||
|
driver-build:
|
||||||
|
runs-on: windows-amd64
|
||||||
|
timeout-minutes: 45
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
# In-tree target dir on purpose: wdk-build's find_top_level_cargo_manifest() walks UP from OUT_DIR
|
||||||
|
# to the first ancestor with a Cargo.lock, so a relocated CARGO_TARGET_DIR (C:\t\…) hides the
|
||||||
|
# workspace lock and it panics. The driver deps have no deep CMake-from-source crates, so the
|
||||||
|
# default in-tree target stays well under MAX_PATH (unlike the SDL3/audiopus client build).
|
||||||
|
working-directory: packaging/windows/drivers
|
||||||
|
env:
|
||||||
|
# wdk-build otherwise picks 10.0.28000.0 (no km/crt) and bindgen fails — pin the WDK SDK version.
|
||||||
|
Version_Number: '10.0.26100.0'
|
||||||
|
# No LIBCLANG_PATH pin: the vendored bindgen 0.72 builds clean on the runner's default clang 22
|
||||||
|
# (the shipping pack proves it). A 0.71-era layout-test overflow once needed LLVM 21; the 0.72 bump
|
||||||
|
# retired that — see design/windows-build-and-packaging.md.
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Ensure WDK + cargo-wdk (idempotent self-provision)
|
||||||
|
# Run the provisioning script here too so driver-build is self-sufficient and never races a
|
||||||
|
# separate provision run on the single runner. Path is relative to the job working-directory
|
||||||
|
# (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||||
|
run: ../../../scripts/ci/provision-windows-wdk.ps1
|
||||||
|
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay)
|
||||||
|
# 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
|
||||||
|
# against IddCxStub end-to-end (M1 step 2 gate).
|
||||||
|
run: cargo build -v
|
||||||
|
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||||
|
run: |
|
||||||
|
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||||
|
$dll = "target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll"
|
||||||
|
if (-not (Test-Path $dll)) { throw "pf_vdisplay.dll not produced at $dll" }
|
||||||
|
$b = [IO.File]::ReadAllBytes($dll)
|
||||||
|
$pe = [BitConverter]::ToInt32($b, 0x3c)
|
||||||
|
$dllchar = [BitConverter]::ToUInt16($b, $pe + 0x5e) # OptionalHeader.DllCharacteristics
|
||||||
|
Write-Host ("pf_vdisplay.dll built OK ({0:N0} bytes)" -f (Get-Item $dll).Length)
|
||||||
|
Write-Host ("BEFORE: DllCharacteristics = 0x{0:X4}; FORCE_INTEGRITY = {1}" -f $dllchar, (($dllchar -band 0x0080) -ne 0))
|
||||||
|
- name: Clear FORCE_INTEGRITY (self-signed-load fix) + verify
|
||||||
|
# wdk-build sets /INTEGRITYCHECK unconditionally -> a self-signed driver won't load. Clear the PE
|
||||||
|
# bit deterministically (the reusable packaging step; signing/.cat happen later for real drivers).
|
||||||
|
run: ../clear-force-integrity.ps1 -Path target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
||||||
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
||||||
# SudoVDA virtual-display driver) from one signed setup.exe. Runs on the self-hosted Windows runner
|
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
||||||
|
# bun) from one signed setup.exe. Runs on the self-hosted Windows runner
|
||||||
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
|
# (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml.
|
||||||
#
|
#
|
||||||
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
|
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
|
||||||
@@ -35,7 +36,8 @@ on:
|
|||||||
- 'crates/punktfunk-host/**'
|
- 'crates/punktfunk-host/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
- 'packaging/windows/**'
|
- 'packaging/windows/**'
|
||||||
- 'scripts/windows/host.env.example'
|
- 'scripts/windows/**'
|
||||||
|
- 'web/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- '.gitea/workflows/windows-host.yml'
|
- '.gitea/workflows/windows-host.yml'
|
||||||
@@ -54,6 +56,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Locale-safety gate (installer-run scripts must be ASCII)
|
||||||
|
shell: pwsh
|
||||||
|
# The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END
|
||||||
|
# USER's box. PS 5.1 reads a BOM-less script in the active ANSI codepage, so on a non-UTF-8 locale
|
||||||
|
# (e.g. German Windows-1252) a stray em-dash mis-decodes into a curly quote and the script aborts
|
||||||
|
# with "unterminated string" - exactly how the pf-vdisplay driver install silently failed in the
|
||||||
|
# field. Keep every installer-run script pure ASCII (matches install-gamepad-drivers.ps1).
|
||||||
|
run: |
|
||||||
|
$bad = Get-ChildItem packaging/windows/*.ps1, scripts/windows/*.ps1, scripts/windows/*.cmd -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { [IO.File]::ReadAllText($_.FullName) -match '[^\x00-\x7F]' }
|
||||||
|
if ($bad) {
|
||||||
|
$bad.FullName | ForEach-Object { Write-Output "::error::non-ASCII in installer-run script: $_" }
|
||||||
|
throw "installer-run scripts must be pure ASCII (PS 5.1 mis-parses them on non-UTF-8 locales)"
|
||||||
|
}
|
||||||
|
Write-Output "installer-run scripts are ASCII-clean"
|
||||||
|
|
||||||
- name: Configure + version
|
- name: Configure + version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -94,6 +112,18 @@ jobs:
|
|||||||
# 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
|
||||||
|
|
||||||
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
|
shell: pwsh
|
||||||
|
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
|
||||||
|
# like Doom use HDR on the virtual display). Lint here so a regression fails CI instead of
|
||||||
|
# silently shipping the host without the layer (pack-host-installer.ps1 builds it non-fatally).
|
||||||
|
# Windows-only FFI (user32 + the vk_layer loader glue) → can't be linted on the Linux CI.
|
||||||
|
run: |
|
||||||
|
Push-Location packaging/windows/pf-vkhdr-layer
|
||||||
|
cargo fmt --check; if ($LASTEXITCODE) { throw "pf-vkhdr-layer rustfmt" }
|
||||||
|
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
- name: Ensure Inno Setup
|
- name: Ensure Inno Setup
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -102,6 +132,59 @@ jobs:
|
|||||||
choco install innosetup -y --no-progress
|
choco install innosetup -y --no-progress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Fetch portable bun runtime (build tool + bundled to run the console)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# ONE pinned bun, used both to BUILD the console and shipped in the installer to RUN it. The
|
||||||
|
# .output is self-contained (Nitro noExternals — deps bundled + tree-shaken, no node_modules),
|
||||||
|
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
|
||||||
|
$ver = 'bun-v1.3.14'
|
||||||
|
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
|
||||||
|
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
|
||||||
|
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $zip
|
||||||
|
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
|
||||||
|
Expand-Archive -Path $zip -DestinationPath $dst -Force
|
||||||
|
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
|
||||||
|
if (-not $bun) { throw "bun.exe not found in $url" }
|
||||||
|
"BUN_EXE=$bun" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
& $bun --version
|
||||||
|
|
||||||
|
- name: Build + smoke-boot web console (bun)
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
# PAT with read access to the unom org packages — the @unom npm registry needs auth to BUILD.
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
# The bun fetched above builds the Nitro server AND runs it. noExternals (vite.config) makes the
|
||||||
|
# output self-contained, so there's no .output/server install — the installer ships bun + the
|
||||||
|
# ~75-file .output. The runner is SYSTEM with no ~/.npmrc, so supply the private @unom token in
|
||||||
|
# the SYSTEM home .npmrc to BUILD (kept OUT of the shipped bundle — web\.npmrc has only the
|
||||||
|
# registry mapping, and nothing copies it into .output).
|
||||||
|
run: |
|
||||||
|
$bun = $env:BUN_EXE
|
||||||
|
if ($env:REGISTRY_TOKEN) {
|
||||||
|
$rc = Join-Path $env:USERPROFILE '.npmrc'
|
||||||
|
Add-Content -Path $rc -Value '@unom:registry=https://git.unom.io/api/packages/unom/npm/'
|
||||||
|
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
|
||||||
|
}
|
||||||
|
Push-Location web
|
||||||
|
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||||
|
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||||
|
if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) {
|
||||||
|
throw "web build is a bun bundle (Bun.serve) - need the node-server preset"
|
||||||
|
}
|
||||||
|
Pop-Location
|
||||||
|
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
||||||
|
$env:PORT = '3009'; $env:HOST = '127.0.0.1'; $env:PUNKTFUNK_UI_PASSWORD = 'ci'
|
||||||
|
$server = (Resolve-Path 'web\.output\server\index.mjs').Path
|
||||||
|
$p = Start-Process -FilePath $bun -ArgumentList $server -PassThru -WindowStyle Hidden
|
||||||
|
Start-Sleep -Seconds 4
|
||||||
|
try { $code = (Invoke-WebRequest -Uri 'http://127.0.0.1:3009/login' -UseBasicParsing -TimeoutSec 10).StatusCode } catch { $code = 0 }
|
||||||
|
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Output "web console smoke (bun): /login -> $code"
|
||||||
|
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||||
|
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
- name: Pack + sign installer
|
- name: Pack + sign installer
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ dist/
|
|||||||
clients/apple/.build/
|
clients/apple/.build/
|
||||||
clients/apple/PunktfunkCore.xcframework/
|
clients/apple/PunktfunkCore.xcframework/
|
||||||
clients/apple/.swiftpm/
|
clients/apple/.swiftpm/
|
||||||
|
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
||||||
|
clients/apple/screenshots/
|
||||||
# Xcode per-user state
|
# Xcode per-user state
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
||||||
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
||||||
[`docs/implementation-plan.md`](docs/implementation-plan.md). Status table: `README.md`.
|
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
|
||||||
|
|
||||||
## Where the work stands
|
## Where the work stands
|
||||||
|
|
||||||
@@ -27,7 +27,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
||||||
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
||||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
||||||
checked-in OpenAPI doc (`mgmt.rs`).
|
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
|
||||||
|
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
|
||||||
|
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
|
||||||
|
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
|
||||||
|
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
|
||||||
|
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
|
||||||
|
boundary, and finished captures are saved as on-disk recordings
|
||||||
|
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
|
||||||
|
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
|
||||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
||||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
||||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||||
@@ -70,10 +78,23 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
||||||
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
||||||
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
||||||
LEDs / mute). The UHID pads need a Linux host; off Linux they (and One/Series) fold into Xbox 360.
|
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
|
||||||
Clients auto-resolve the type from the physical controller (DS5→DualSense, DS4→DualShock 4,
|
(UMDF minidriver)** backend — `inject/dualsense_windows.rs` + `inject/dualshock4_windows.rs`, one
|
||||||
Xbox One→Xbox One). Windows-host DualShock 4 (ViGEm) is not yet wired — Windows clients asking for
|
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
|
||||||
DS4 get Xbox 360 for now.
|
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
|
||||||
|
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
|
||||||
|
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
|
||||||
|
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
|
||||||
|
(`packaging/windows/xusb-driver/`, `inject/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
|
||||||
|
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
|
||||||
|
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 bundled + pnputil-installed
|
||||||
|
by the Inno Setup installer (`packaging/windows/gamepad-drivers/` + `install-gamepad-drivers.ps1`).
|
||||||
|
**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
|
||||||
|
`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
|
||||||
|
the remaining piece.)
|
||||||
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
|
||||||
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
|
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
|
||||||
virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel
|
virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel
|
||||||
@@ -91,9 +112,16 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
||||||
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
||||||
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
||||||
host only** (the Linux host stays 8-bit, blocked upstream). **AMF/QSV is CI-green but not yet
|
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
|
||||||
on-glass validated** (no AMD/Intel Windows box in the lab); NVENC is live-validated. Newer/less
|
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
|
||||||
battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
|
||||||
|
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
|
||||||
|
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
|
||||||
|
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
|
||||||
|
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
|
||||||
|
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
|
||||||
|
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
||||||
|
|
||||||
## What's left
|
## What's left
|
||||||
|
|
||||||
@@ -186,7 +214,9 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
||||||
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
||||||
Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
Opus/Oboe audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
||||||
(`feedback.rs`), `NsdManager` mDNS discovery, SPAKE2 PIN pairing + TOFU (Keystore identity +
|
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
|
||||||
|
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
|
||||||
|
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
|
||||||
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
||||||
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
||||||
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
||||||
@@ -230,8 +260,8 @@ bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip
|
|||||||
```
|
```
|
||||||
|
|
||||||
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
||||||
(cbindgen from `punktfunk-core/src/abi.rs`) and `docs/api/openapi.json` (regenerate with
|
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
|
||||||
`cargo run -p punktfunk-host -- openapi > docs/api/openapi.json`; spec lives in `mgmt.rs`).
|
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
|
||||||
|
|
||||||
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
||||||
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
||||||
@@ -253,7 +283,7 @@ crates/punktfunk-host/
|
|||||||
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||||
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
||||||
encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264)
|
encode/{nvenc,linux,vaapi,ffmpeg_win,sw}.rs per-GPU encoders (NVENC · Linux NVENC/CUDA · VAAPI · AMF/QSV · openh264)
|
||||||
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs
|
capture.rs · encode.rs · audio.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs
|
||||||
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)
|
||||||
@@ -261,7 +291,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
|
||||||
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
|
crates/punktfunk-host/src/{capture/dxgi,vdisplay/sudovda,encode/ffmpeg_win,inject/gamepad_windows,audio/wasapi_*,service}.rs Windows host backends
|
||||||
web/ TanStack web console over the mgmt API (status · devices · pairing)
|
web/ TanStack web console over the mgmt API (status · devices · pairing · 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)
|
||||||
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
||||||
|
|||||||
Generated
+446
-33
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aead"
|
name = "aead"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -453,6 +459,20 @@ name = "bytemuck"
|
|||||||
version = "1.25.0"
|
version = "1.25.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck_derive"
|
||||||
|
version = "1.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
@@ -721,6 +741,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "criterion"
|
name = "criterion"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -996,6 +1025,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastbloom"
|
name = "fastbloom"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@@ -1074,6 +1115,16 @@ version = "0.5.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1097,6 +1148,12 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1572,7 +1629,16 @@ version = "0.15.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash",
|
"foldhash 0.1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1580,6 +1646,18 @@ name = "hashbrown"
|
|||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
@@ -1698,12 +1776,115 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_collections"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"potential_utf",
|
||||||
|
"utf8_iter",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_core"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"litemap",
|
||||||
|
"tinystr",
|
||||||
|
"writeable",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||||
|
dependencies = [
|
||||||
|
"icu_collections",
|
||||||
|
"icu_normalizer_data",
|
||||||
|
"icu_properties",
|
||||||
|
"icu_provider",
|
||||||
|
"smallvec",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer_data"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||||
|
dependencies = [
|
||||||
|
"icu_collections",
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_properties_data",
|
||||||
|
"icu_provider",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties_data"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_locale_core",
|
||||||
|
"writeable",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "id-arena"
|
name = "id-arena"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||||
|
dependencies = [
|
||||||
|
"idna_adapter",
|
||||||
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna_adapter"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||||
|
dependencies = [
|
||||||
|
"icu_normalizer",
|
||||||
|
"icu_properties",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "if-addrs"
|
name = "if-addrs"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
@@ -1952,12 +2133,29 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.38.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litemap"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -2052,6 +2250,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -2404,6 +2612,13 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pf-driver-proto"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2477,6 +2692,15 @@ dependencies = [
|
|||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "potential_utf"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||||
|
dependencies = [
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2547,6 +2771,7 @@ dependencies = [
|
|||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
"mdns-sd",
|
||||||
"ndk",
|
"ndk",
|
||||||
"opus",
|
"opus",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
@@ -2633,6 +2858,8 @@ dependencies = [
|
|||||||
"audiopus_sys",
|
"audiopus_sys",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-server",
|
"axum-server",
|
||||||
|
"base64",
|
||||||
|
"bytemuck",
|
||||||
"cbc",
|
"cbc",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -2647,13 +2874,16 @@ dependencies = [
|
|||||||
"nvidia-video-codec-sdk",
|
"nvidia-video-codec-sdk",
|
||||||
"openh264",
|
"openh264",
|
||||||
"opus",
|
"opus",
|
||||||
|
"pf-driver-proto",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reis",
|
"reis",
|
||||||
|
"roxmltree",
|
||||||
"rsa",
|
"rsa",
|
||||||
|
"rusqlite",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"rusty_enet",
|
"rusty_enet",
|
||||||
@@ -2665,10 +2895,10 @@ dependencies = [
|
|||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"ureq",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-axum",
|
"utoipa-axum",
|
||||||
"utoipa-scalar",
|
"utoipa-scalar",
|
||||||
"vigem-client",
|
|
||||||
"wasapi",
|
"wasapi",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
@@ -2677,6 +2907,7 @@ dependencies = [
|
|||||||
"wayland-scanner",
|
"wayland-scanner",
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"windows-service",
|
"windows-service",
|
||||||
|
"winreg",
|
||||||
"x509-parser",
|
"x509-parser",
|
||||||
"xkbcommon",
|
"xkbcommon",
|
||||||
]
|
]
|
||||||
@@ -2979,6 +3210,15 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roxmltree"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rpkg-config"
|
name = "rpkg-config"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -3005,6 +3245,31 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsqlite-vfs"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.40.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
"sqlite-wasm-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -3455,6 +3720,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -3525,6 +3796,24 @@ dependencies = [
|
|||||||
"der",
|
"der",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlite-wasm-rs"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"js-sys",
|
||||||
|
"rsqlite-vfs",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stable_deref_trait"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -3677,6 +3966,16 @@ dependencies = [
|
|||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinystr"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinytemplate"
|
name = "tinytemplate"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -3982,6 +4281,40 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "2.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"url",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "url"
|
||||||
|
version = "2.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"idna",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8_iter"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -4072,15 +4405,6 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vigem-client"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b857e6f99efe1e1eb1e4dfb035de8ae7ec8ec56bd1928edcbd7c6e4427634d52"
|
|
||||||
dependencies = [
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wait-timeout"
|
name = "wait-timeout"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -4318,6 +4642,24 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wide"
|
name = "wide"
|
||||||
version = "0.7.33"
|
version = "0.7.33"
|
||||||
@@ -4334,22 +4676,6 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi"
|
|
||||||
version = "0.3.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
|
||||||
dependencies = [
|
|
||||||
"winapi-i686-pc-windows-gnu",
|
|
||||||
"winapi-x86_64-pc-windows-gnu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-i686-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -4359,12 +4685,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -4865,6 +5185,16 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.56.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
@@ -4959,6 +5289,12 @@ dependencies = [
|
|||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "writeable"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x509-parser"
|
name = "x509-parser"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -5002,6 +5338,29 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
"yoke-derive",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke-derive"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus"
|
name = "zbus"
|
||||||
version = "5.16.0"
|
version = "5.16.0"
|
||||||
@@ -5078,12 +5437,66 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||||
|
dependencies = [
|
||||||
|
"zerofrom-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom-derive"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerotrie"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec"
|
||||||
|
version = "0.11.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||||
|
dependencies = [
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec-derive"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"crates/punktfunk-core",
|
"crates/punktfunk-core",
|
||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
|
"crates/pf-driver-proto",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
"clients/windows",
|
"clients/windows",
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
# punktfunk
|
<p align="center">
|
||||||
|
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
|
||||||
|
</p>
|
||||||
|
|
||||||
**Low-latency desktop and game streaming, Linux-first.** Run the host on a Linux machine — or a
|
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
|
||||||
Windows PC — with an NVIDIA GPU, connect from a Mac, PC, phone, tablet, or TV, and stream your desktop
|
|
||||||
or games — each device at its **own native resolution and refresh rate**, over your local network.
|
Run the host on a Linux machine or a Windows PC, connect from a Mac, PC, phone, tablet, or TV, and
|
||||||
|
stream your desktop or games — each device at its **own native resolution and refresh rate**, over
|
||||||
|
your local network.
|
||||||
|
|
||||||
📖 **Documentation: [docs.punktfunk.unom.io](https://docs.punktfunk.unom.io)** — start with
|
📖 **Documentation: [docs.punktfunk.unom.io](https://docs.punktfunk.unom.io)** — start with
|
||||||
[How It Works](https://docs.punktfunk.unom.io/docs/how-it-works) or the
|
[How It Works](https://docs.punktfunk.unom.io/docs/how-it-works) or the
|
||||||
[Quick Start](https://docs.punktfunk.unom.io/docs/quickstart).
|
[Quick Start](https://docs.punktfunk.unom.io/docs/quickstart).
|
||||||
|
|
||||||
|
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
|
||||||
|
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
||||||
|
|
||||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||||
@@ -19,6 +26,11 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
|
|||||||
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
|
||||||
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
|
||||||
letterboxing, no scaling, no rearranging your real monitors.
|
letterboxing, no scaling, no rearranging your real monitors.
|
||||||
|
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
|
||||||
|
on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical
|
||||||
|
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
|
||||||
|
indirect display driver (IDD)** the host pushes finished frames straight into, rather than scraping
|
||||||
|
a screen — tight, push-based integration that's unusual for a Windows streaming host.
|
||||||
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
|
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
|
||||||
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
|
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
|
||||||
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN.
|
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on a LAN.
|
||||||
@@ -35,7 +47,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** (NVIDIA, x64) | 🟡 Implemented & shipping as a signed installer (DXGI capture · SudoVDA virtual display · NVENC · WASAPI · ViGEm); NVIDIA-only, newer than the Linux host |
|
| **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) · 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, Oboe audio, controllers, discovery, pairing |
|
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, Oboe audio, controllers, discovery, pairing |
|
||||||
@@ -61,14 +73,14 @@ roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
|
|||||||
|
|
||||||
Pick your platform and install from its package registry — the per-platform guide covers adding the
|
Pick your platform and install from its package registry — the per-platform guide covers adding the
|
||||||
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
|
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
|
||||||
Windows host (NVIDIA-only) also ships as a signed installer.
|
Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||||
|
|
||||||
| Platform | Install | Guide |
|
| Platform | Install | Guide |
|
||||||
|--------|---------|-------|
|
|--------|---------|-------|
|
||||||
| **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** (NVIDIA, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
| **Windows** (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;
|
||||||
@@ -113,7 +125,7 @@ and the [docs site](https://docs.punktfunk.unom.io).
|
|||||||
```
|
```
|
||||||
crates/
|
crates/
|
||||||
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
|
||||||
punktfunk-host/ Linux host: virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
|
||||||
clients/
|
clients/
|
||||||
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
|
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
|
||||||
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
|
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3)
|
||||||
@@ -124,7 +136,7 @@ clients/
|
|||||||
web/ web console (TanStack) over the management API — status · devices · pairing
|
web/ web console (TanStack) over the management API — status · devices · pairing
|
||||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||||
docs/ design notes & deep-dive plans
|
design/ design notes & deep-dive plans (index: design/README.md)
|
||||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||||
tools/ latency-probe · loss-harness (measurement)
|
tools/ latency-probe · loss-harness (measurement)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -978,6 +978,309 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/stats/capture/live": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Live in-progress capture",
|
||||||
|
"description": "The full sample time-series of the capture currently recording, for live graphing. `404` when\nnothing is armed.",
|
||||||
|
"operationId": "statsCaptureLive",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The in-progress capture (meta + samples so far)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Capture"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No capture is currently recording",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/capture/start": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Start a stats capture",
|
||||||
|
"description": "Arms a new performance-stats capture. Idempotent: if a capture is already running this returns\nthe current status unchanged. While armed, the streaming loops emit aggregated samples (~ every\n1–2 s) into the in-progress capture, readable live via `GET /stats/capture/live`.",
|
||||||
|
"operationId": "statsCaptureStart",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Capture armed (or already running)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/StatsStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/capture/status": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Stats capture status",
|
||||||
|
"description": "Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to\ndrive the capture-control UI.",
|
||||||
|
"operationId": "statsCaptureStatus",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "In-progress capture status (idle when not armed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/StatsStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/capture/stop": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Stop the stats capture",
|
||||||
|
"description": "Disarms the in-progress capture and writes it to disk atomically, returning its summary. If\nnothing was recording, returns `204 No Content`.",
|
||||||
|
"operationId": "statsCaptureStop",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Capture stopped and saved",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CaptureMeta"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"204": {
|
||||||
|
"description": "Nothing was recording"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not write the recording to disk",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/recordings": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "List saved recordings",
|
||||||
|
"description": "Every saved capture's summary (the `meta` head only — not the sample body), newest first.",
|
||||||
|
"operationId": "statsRecordingsList",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Saved capture summaries, newest first",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/CaptureMeta"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/stats/recordings/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Get a saved recording",
|
||||||
|
"description": "The full capture (meta + samples) for `id`, for graphing or download.",
|
||||||
|
"operationId": "statsRecordingGet",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The recording id (its filename stem)",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The full capture",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Capture"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No recording with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "The recording file is unreadable",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"summary": "Delete a saved recording",
|
||||||
|
"description": "Removes the recording `id` from disk. `404` if there is no such recording.",
|
||||||
|
"operationId": "statsRecordingDelete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The recording id (its filename stem)",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Recording deleted"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No recording with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not delete the recording",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/status": {
|
"/api/v1/status": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1125,6 +1428,89 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Capture": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A full capture: summary + the sample time-series. The wire + on-disk shape.",
|
||||||
|
"required": [
|
||||||
|
"meta",
|
||||||
|
"samples"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/CaptureMeta"
|
||||||
|
},
|
||||||
|
"samples": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/StatsSample"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CaptureMeta": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head\nof each on-disk recording and listed standalone (without the sample body) by\n[`StatsRecorder::list`].",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"started_unix_ms",
|
||||||
|
"duration_ms",
|
||||||
|
"kind",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"fps",
|
||||||
|
"codec",
|
||||||
|
"client",
|
||||||
|
"sample_count"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"client": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short label / fingerprint prefix, or `\"\"` if unknown."
|
||||||
|
},
|
||||||
|
"codec": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`\"h264\" | \"hevc\" | \"av1\"`."
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"fps": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem."
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`\"native\" | \"gamestream\"`."
|
||||||
|
},
|
||||||
|
"sample_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"started_unix_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CustomEntry": {
|
"CustomEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
|
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
|
||||||
@@ -1595,6 +1981,144 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"StageTiming": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One pipeline stage's latency in an aggregation window (microseconds).",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"p50_us",
|
||||||
|
"p99_us"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`\"capture\" | \"submit\" | \"encode\" | \"packetize\" | \"send\"` (path-dependent)."
|
||||||
|
},
|
||||||
|
"p50_us": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float"
|
||||||
|
},
|
||||||
|
"p99_us": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"StatsSample": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).",
|
||||||
|
"required": [
|
||||||
|
"t_ms",
|
||||||
|
"session_id",
|
||||||
|
"stages",
|
||||||
|
"fps",
|
||||||
|
"repeat_fps",
|
||||||
|
"mbps",
|
||||||
|
"bitrate_kbps",
|
||||||
|
"frames_dropped",
|
||||||
|
"packets_dropped",
|
||||||
|
"send_dropped",
|
||||||
|
"fec_recovered"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"bitrate_kbps": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Configured target bitrate.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"fec_recovered": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "FEC shards recovered this window (delta).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"fps": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float",
|
||||||
|
"description": "Genuine NEW frames/s from the source."
|
||||||
|
},
|
||||||
|
"frames_dropped": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Frames dropped this window (delta).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"mbps": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float",
|
||||||
|
"description": "Transmit goodput (Mb/s)."
|
||||||
|
},
|
||||||
|
"packets_dropped": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Packets dropped this window (receiver-side / reassembler, where known).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"repeat_fps": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float",
|
||||||
|
"description": "Re-encoded holds/s (source-starvation indicator)."
|
||||||
|
},
|
||||||
|
"send_dropped": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Host send-buffer overflow / EAGAIN this window (delta).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Disambiguates concurrent sessions (usually constant).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"stages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/StageTiming"
|
||||||
|
},
|
||||||
|
"description": "Ordered pipeline stages for this path."
|
||||||
|
},
|
||||||
|
"t_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"StatsStatus": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Snapshot of the in-progress capture for the management API.",
|
||||||
|
"required": [
|
||||||
|
"armed",
|
||||||
|
"sample_count",
|
||||||
|
"started_unix_ms",
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"armed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Capture currently running."
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path of the in-progress capture (`\"\"` if idle)."
|
||||||
|
},
|
||||||
|
"sample_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Samples in the in-progress capture.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"started_unix_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Unix start time of the in-progress capture (`0` if idle).",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"StreamInfo": {
|
"StreamInfo": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "RTSP-negotiated stream parameters.",
|
"description": "RTSP-negotiated stream parameters.",
|
||||||
@@ -1696,6 +2220,10 @@
|
|||||||
{
|
{
|
||||||
"name": "library",
|
"name": "library",
|
||||||
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
|
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stats",
|
||||||
|
"description": "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 579 298" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<style>
|
||||||
|
/* Theme-adaptive so the logo stays readable on both light and dark README
|
||||||
|
backgrounds: deep violet (the brand-mark palette) on light, the original
|
||||||
|
light violet on dark. Evaluated by the viewer's color scheme. */
|
||||||
|
.pf-wm { fill: #6c5bf3; }
|
||||||
|
.pf-back { fill: #a79ff8; }
|
||||||
|
.pf-deep { fill: #6c5bf3; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.pf-wm { fill: #cec9fb; }
|
||||||
|
.pf-back { fill: #f2f1fe; }
|
||||||
|
.pf-deep { fill: #8c7ef5; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M21.144,176.635l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z"/>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M136.148,176.635l0,47.264c0.154,16.627 0.154,16.627 0.308,20.014c0.77,15.087 2.463,21.4 7.544,26.634c7.698,8.16 20.014,10.315 59.272,10.315c23.863,0 34.178,-0.616 43.415,-2.463c11.7,-2.463 19.552,-10.623 21.246,-22.323c0.924,-7.236 1.078,-8.929 1.54,-32.176l0,-47.264l-31.253,0l0,47.264c0,2.155 -0.154,7.082 -0.308,10.623c-0.462,9.699 -1.232,12.47 -3.695,15.087c-3.387,3.695 -9.853,4.619 -31.407,4.619c-26.634,0 -32.638,-1.693 -34.332,-9.853c-0.77,-4.157 -0.77,-4.311 -1.078,-20.476l0,-47.264l-31.253,0Z"/>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M275.938,176.527l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z"/>
|
||||||
|
<path class="pf-wm" style="fill-rule:nonzero;" d="M425.273,176.527l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z"/>
|
||||||
|
</g>
|
||||||
|
<path class="pf-back" style="fill-rule:nonzero;" d="M65.442,150.143c24.514,0 44.298,-19.784 44.298,-44.298c0,-24.514 -19.784,-44.298 -44.298,-44.298c-24.514,0 -44.298,19.784 -44.298,44.298c0,24.514 19.784,44.298 44.298,44.298Z"/>
|
||||||
|
<path class="pf-deep" style="fill-rule:nonzero;" d="M141.063,92.871c17.334,-17.334 17.334,-45.312 0,-62.647c-17.334,-17.334 -45.312,-17.334 -62.647,-0c-17.334,17.334 -17.334,45.312 0,62.647c17.334,17.334 45.312,17.334 62.647,-0Z"/>
|
||||||
|
<path style="fill:url(#_Linear1);" d="M121.228,104.359c-14.777,3.965 -31.187,0.136 -42.811,-11.488c-11.624,-11.624 -15.453,-28.034 -11.488,-42.811c14.777,-3.965 31.187,-0.136 42.811,11.488c11.624,11.624 15.453,28.034 11.488,42.811Z"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(31.323323,-31.323323,31.323323,31.323323,78.416832,92.870811)">
|
||||||
|
<stop offset="0" style="stop-color:#cec9fb;stop-opacity:0"/>
|
||||||
|
<stop offset="1" style="stop-color:#fcfcff;stop-opacity:1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -11,8 +11,8 @@ machine, trust logic) instead of re-porting it into Kotlin.
|
|||||||
|
|
||||||
| Side | Owns |
|
| Side | Owns |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **Rust** (`clients/android/native` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing |
|
| **Rust** (`clients/android/native` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing, **mDNS discovery** (`mdns-sd`, the same browse the Linux/Windows clients use) |
|
||||||
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions |
|
| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, the Wi-Fi `MulticastLock` + permission UX, Keystore identity, permissions |
|
||||||
|
|
||||||
The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`.
|
The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`.
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ clients/android/native/ Rust cdylib (workspace member) — links punktf
|
|||||||
clients/android/ Gradle project (this dir)
|
clients/android/ Gradle project (this dir)
|
||||||
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
|
settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew
|
||||||
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
|
app/ :app — Compose UI: Connect / Settings / Stream screens (phone + TV)
|
||||||
kit/ :kit — NativeBridge · discovery (NsdManager) · Gamepad · Keymap ·
|
kit/ :kit — NativeBridge · discovery (native mdns-sd, polled) · Gamepad · Keymap ·
|
||||||
security (Keystore identity + known-host store) · cargo-ndk build
|
security (Keystore identity + known-host store) · cargo-ndk build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -74,7 +74,8 @@ streaming experience:
|
|||||||
- **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host.
|
- **Audio** — Opus + Oboe playback with a jitter ring, plus mic uplink to the host.
|
||||||
- **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
|
- **Input** — game controllers (buttons + axes) with rumble and HID feedback; D-pad /
|
||||||
game-controller focus navigation for the couch (TV + phone).
|
game-controller focus navigation for the couch (TV + phone).
|
||||||
- **Discovery & trust** — `NsdManager` mDNS host list, SPAKE2 PIN pairing and TOFU, with a
|
- **Discovery & trust** — native `mdns-sd` mDNS host list (polled over JNI; the same browse the
|
||||||
|
Linux/Windows clients use, not `NsdManager`), SPAKE2 PIN pairing and TOFU, with a
|
||||||
Keystore-wrapped client identity and a known-host store.
|
Keystore-wrapped client identity and a known-host store.
|
||||||
- **UI** — Compose host list / settings / stream screens, Material You theming.
|
- **UI** — Compose host list / settings / stream screens, Material You theming.
|
||||||
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
|
- **Shipping** — built for `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing).
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
<!-- punktfunk/1 QUIC/UDP data plane. -->
|
<!-- punktfunk/1 QUIC/UDP data plane. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<!-- mDNS discovery of _punktfunk._udp on the LAN (NsdManager). -->
|
<!-- mDNS discovery of _punktfunk._udp on the LAN (native mdns-sd browse). Requested
|
||||||
|
opportunistically — raw multicast reception needs only the MulticastLock, not this. -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
<!-- Hold a MulticastLock while NsdManager discovery runs (OEM Wi-Fi power-save hedge). -->
|
<!-- HostDiscovery holds a MulticastLock while the native mDNS browse runs — raw multicast
|
||||||
|
reception needs it (also an OEM Wi-Fi power-save hedge). -->
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
|
||||||
|
|||||||
@@ -84,30 +84,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var host by remember { mutableStateOf("") }
|
var host by remember { mutableStateOf("") }
|
||||||
|
var hostName by remember { mutableStateOf("") }
|
||||||
var port by remember { mutableStateOf("9777") }
|
var port by remember { mutableStateOf("9777") }
|
||||||
var connecting by remember { mutableStateOf(false) }
|
var connecting by remember { mutableStateOf(false) }
|
||||||
var status by remember { mutableStateOf<String?>(null) }
|
var status by remember { mutableStateOf<String?>(null) }
|
||||||
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||||
val (w, h, hz) = settings.effectiveMode(context)
|
val (w, h, hz) = settings.effectiveMode(context)
|
||||||
|
|
||||||
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
|
// mDNS discovery scoped to this screen, via the native mdns-sd browse (HostDiscovery) — its
|
||||||
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
|
// onChange fires on the main thread, so it can set Compose state directly. (Emulator SLIRP drops
|
||||||
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without
|
// multicast → empty; that's the network, not the API.) Raw multicast reception only needs the
|
||||||
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant.
|
// Wi-Fi MulticastLock (HostDiscovery holds it), NOT NEARBY_WIFI_DEVICES — that gated the old
|
||||||
|
// NsdManager path. We still request NEARBY_WIFI_DEVICES opportunistically (some OEMs filter
|
||||||
|
// multicast without it; harmless where it isn't), but never block discovery on the grant — a
|
||||||
|
// denial used to leave discovery dead forever.
|
||||||
val discovery = remember { HostDiscovery(context) }
|
val discovery = remember { HostDiscovery(context) }
|
||||||
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
|
||||||
var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
|
|
||||||
val nearbyLauncher = rememberLauncherForActivityResult(
|
val nearbyLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { granted -> nearbyGranted = granted }
|
) { _ -> /* best-effort hint; discovery runs regardless of the result */ }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNearbyPermission(context)) {
|
||||||
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(nearbyGranted) {
|
DisposableEffect(Unit) {
|
||||||
discovery.onChange = { discovered = it }
|
discovery.onChange = { discovered = it }
|
||||||
if (nearbyGranted) discovery.start()
|
discovery.start()
|
||||||
onDispose {
|
onDispose {
|
||||||
discovery.onChange = null
|
discovery.onChange = null
|
||||||
discovery.stop()
|
discovery.stop()
|
||||||
@@ -127,6 +130,13 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
|
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
|
||||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||||
|
// A saved host whose label is being edited (the Rename dialog).
|
||||||
|
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
|
||||||
|
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
|
||||||
|
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
|
||||||
|
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||||
|
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
||||||
|
|
||||||
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
|
||||||
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
|
||||||
@@ -176,10 +186,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
||||||
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
||||||
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
|
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
|
||||||
fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
|
fun connect(
|
||||||
|
targetHost: String,
|
||||||
|
targetPort: Int,
|
||||||
|
dh: DiscoveredHost? = null,
|
||||||
|
manualName: String? = null,
|
||||||
|
) {
|
||||||
val known = knownHostStore.get(targetHost, targetPort)
|
val known = knownHostStore.get(targetHost, targetPort)
|
||||||
val adv = dh?.fingerprint?.lowercase()
|
val adv = dh?.fingerprint?.lowercase()
|
||||||
val name = dh?.name ?: targetHost
|
// Label precedence: a saved host keeps its (possibly user-renamed) name; else the discovered
|
||||||
|
// mDNS name; else the name typed in the Add-host sheet; else the bare address.
|
||||||
|
val name = known?.name ?: dh?.name ?: manualName?.trim()?.takeIf { it.isNotEmpty() } ?: targetHost
|
||||||
when {
|
when {
|
||||||
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
||||||
known != null && (adv == null || adv == known.fpHex) ->
|
known != null && (adv == null || adv == known.fpHex) ->
|
||||||
@@ -260,7 +277,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedHosts.isEmpty() && discovered.isEmpty()) {
|
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
EmptyHostsState()
|
EmptyHostsState()
|
||||||
}
|
}
|
||||||
@@ -281,16 +298,17 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
knownHostStore.remove(kh.address, kh.port)
|
knownHostStore.remove(kh.address, kh.port)
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
},
|
},
|
||||||
|
onRename = { renameTarget = kh },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discovered.isNotEmpty()) {
|
if (discoveredUnsaved.isNotEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
SectionLabel("Discovered on the network")
|
SectionLabel("Discovered on the network")
|
||||||
}
|
}
|
||||||
items(discovered, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
|
||||||
HostCard(
|
HostCard(
|
||||||
name = dh.name,
|
name = dh.name,
|
||||||
address = "${dh.host}:${dh.port}",
|
address = "${dh.host}:${dh.port}",
|
||||||
@@ -302,9 +320,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active-discovery hint: when we're scanning but nothing's turned up yet, show it's
|
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
|
||||||
// working rather than looking idle/empty.
|
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
|
||||||
if (nearbyGranted && discovered.isEmpty()) {
|
// rather than looking idle/empty.
|
||||||
|
if (!connecting && discovered.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||||
@@ -363,6 +382,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = hostName,
|
||||||
|
onValueChange = { hostName = it },
|
||||||
|
label = { Text("Name (optional)") },
|
||||||
|
placeholder = { Text("e.g. Living Room") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = host,
|
value = host,
|
||||||
onValueChange = { host = it },
|
onValueChange = { host = it },
|
||||||
@@ -370,7 +398,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = port,
|
value = port,
|
||||||
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
|
||||||
@@ -385,9 +413,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
val h = host.trim()
|
val h = host.trim()
|
||||||
val p = port.toIntOrNull() ?: 9777
|
val p = port.toIntOrNull() ?: 9777
|
||||||
|
val n = hostName
|
||||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||||
showManualSheet = false
|
showManualSheet = false
|
||||||
connect(h, p)
|
connect(h, p, manualName = n)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -507,10 +536,57 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||||
|
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||||
|
renameTarget?.let { kh ->
|
||||||
|
var newName by remember(kh) { mutableStateOf(kh.name) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { renameTarget = null },
|
||||||
|
title = { Text("Rename host") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newName,
|
||||||
|
onValueChange = { newName = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
placeholder = { Text(kh.address) },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = newName.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
knownHostStore.rename(kh.address, kh.port, newName.trim())
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
renameTarget = null
|
||||||
|
},
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { renameTarget = null }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */
|
/**
|
||||||
|
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
|
||||||
|
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
|
||||||
|
* the native core + MulticastLock) does not depend on it.
|
||||||
|
*/
|
||||||
fun hasNearbyPermission(context: Context): Boolean =
|
fun hasNearbyPermission(context: Context): Boolean =
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a saved host and a discovered advert are the same machine — matched by certificate
|
||||||
|
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
|
||||||
|
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
|
||||||
|
*/
|
||||||
|
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
|
||||||
|
val advFp = dh.fingerprint?.lowercase()
|
||||||
|
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
|
||||||
|
return address == dh.host && port == dh.port
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import android.content.pm.PackageManager
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -16,14 +14,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -33,7 +31,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -174,12 +171,8 @@ private fun ToggleRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
|
||||||
* A labelled value that opens a menu on click. Uses a clickable [Surface] + [DropdownMenu] rather
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
* than `ExposedDropdownMenuBox` — that component's read-only text field traps D-pad / controller
|
|
||||||
* focus (directional keys never leave it), so you can't navigate past it on a TV. Calls [onSelect]
|
|
||||||
* on a pick. A primary-colour border marks D-pad focus.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> SettingDropdown(
|
private fun <T> SettingDropdown(
|
||||||
label: String,
|
label: String,
|
||||||
@@ -188,35 +181,20 @@ private fun <T> SettingDropdown(
|
|||||||
onSelect: (T) -> Unit,
|
onSelect: (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
var focused by remember { mutableStateOf(false) }
|
|
||||||
val selectedLabel = options.firstOrNull { it.first == selected }?.second
|
val selectedLabel = options.firstOrNull { it.first == selected }?.second
|
||||||
?: options.firstOrNull()?.second.orEmpty()
|
?: options.firstOrNull()?.second.orEmpty()
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||||
Surface(
|
OutlinedTextField(
|
||||||
onClick = { expanded = true },
|
value = selectedLabel,
|
||||||
shape = MaterialTheme.shapes.small,
|
onValueChange = {},
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
readOnly = true,
|
||||||
border = if (focused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
label = { Text(label) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||||
.onFocusChanged { focused = it.isFocused },
|
.fillMaxWidth(),
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
Text(selectedLabel, style = MaterialTheme.typography.bodyLarge)
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
}
|
|
||||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
|
||||||
options.forEach { (value, lbl) ->
|
options.forEach { (value, lbl) ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(lbl) },
|
text = { Text(lbl) },
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.positionChange
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -44,6 +43,13 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
|
||||||
|
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
|
||||||
|
// two-finger pan per wheel notch (smaller = faster scroll).
|
||||||
|
private const val TAP_SLOP = 12f
|
||||||
|
private const val TAP_DRAG_MS = 250L
|
||||||
|
private const val SCROLL_DIV = 4f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -139,41 +145,108 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
if (showStats) {
|
if (showStats) {
|
||||||
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||||
}
|
}
|
||||||
// Touch virtual-trackpad overlay: 1-finger drag → relative mouse move; tap → left click;
|
// Touch → mouse, absolute "direct pointing" like the Apple client: the host cursor follows
|
||||||
// 2-finger drag → scroll; 3-finger tap → toggle the stats HUD. (Physical-mouse pointer
|
// your finger (MouseMoveAbs, host-normalized against the overlay size — which fills the video,
|
||||||
// capture comes in a later increment.)
|
// so finger position maps straight onto the remote screen). Gestures: tap = left click;
|
||||||
|
// two-finger tap = right click; two-finger drag = scroll; tap-then-press-and-drag = left-drag
|
||||||
|
// (text selection / moving windows); three-finger tap = toggle the stats HUD.
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle) {
|
Modifier.fillMaxSize().pointerInput(handle) {
|
||||||
|
var lastTapUp = 0L
|
||||||
|
var lastTapX = 0f
|
||||||
|
var lastTapY = 0f
|
||||||
|
fun moveAbs(x: Float, y: Float) {
|
||||||
|
val sw = size.width
|
||||||
|
val sh = size.height
|
||||||
|
if (sw <= 0 || sh <= 0) return
|
||||||
|
NativeBridge.nativeSendPointerAbs(
|
||||||
|
handle,
|
||||||
|
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
|
||||||
|
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
|
||||||
|
sw,
|
||||||
|
sh,
|
||||||
|
)
|
||||||
|
}
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val first = awaitFirstDown(requireUnconsumed = false)
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val startX = down.position.x
|
||||||
|
val startY = down.position.y
|
||||||
|
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||||
|
// button for this whole gesture (laptop-trackpad convention).
|
||||||
|
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
|
||||||
|
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
|
||||||
|
lastTapUp = 0L // consume the arming either way
|
||||||
|
moveAbs(startX, startY) // cursor jumps to the finger immediately
|
||||||
|
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||||
|
|
||||||
var moved = false
|
var moved = false
|
||||||
var maxFingers = 1
|
var maxFingers = 1
|
||||||
|
var scrolling = false
|
||||||
|
var prevCx = startX
|
||||||
|
var prevCy = startY
|
||||||
|
var upTime = down.uptimeMillis
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
val fingers = ev.changes.count { it.pressed }
|
val pressed = ev.changes.filter { it.pressed }
|
||||||
if (fingers == 0) break
|
if (pressed.isEmpty()) {
|
||||||
if (fingers > maxFingers) maxFingers = fingers
|
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
||||||
val primary = ev.changes.firstOrNull { it.id == first.id } ?: ev.changes.first()
|
break
|
||||||
val d = primary.positionChange()
|
|
||||||
if (abs(d.x) > 0.5f || abs(d.y) > 0.5f) {
|
|
||||||
moved = true
|
|
||||||
if (fingers >= 2) {
|
|
||||||
// screen +y down → wire +up, so negate y. Coarse divisor; tune live.
|
|
||||||
val sy = (-d.y / 4f).toInt()
|
|
||||||
val sx = (d.x / 4f).toInt()
|
|
||||||
if (sy != 0) NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
|
||||||
if (sx != 0) NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
|
||||||
} else {
|
|
||||||
NativeBridge.nativeSendPointerMove(handle, d.x.toInt(), d.y.toInt())
|
|
||||||
}
|
}
|
||||||
|
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||||
|
|
||||||
|
if (pressed.size >= 2) {
|
||||||
|
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||||
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
|
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||||
|
if (!scrolling) {
|
||||||
|
scrolling = true
|
||||||
|
prevCx = cx
|
||||||
|
prevCy = cy
|
||||||
|
}
|
||||||
|
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
||||||
|
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
||||||
|
if (sy != 0) {
|
||||||
|
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||||
|
prevCy = cy
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
if (sx != 0) {
|
||||||
|
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||||
|
prevCx = cx
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
} else if (!scrolling) {
|
||||||
|
// One finger → the cursor follows it (skipped once a gesture turned into
|
||||||
|
// a scroll, so dropping back to one finger doesn't jerk the cursor).
|
||||||
|
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
|
||||||
|
if (abs(p.position.x - startX) > TAP_SLOP ||
|
||||||
|
abs(p.position.y - startY) > TAP_SLOP
|
||||||
|
) {
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
moveAbs(p.position.x, p.position.y)
|
||||||
}
|
}
|
||||||
ev.changes.forEach { it.consume() }
|
ev.changes.forEach { it.consume() }
|
||||||
}
|
}
|
||||||
if (!moved && maxFingers == 1) {
|
|
||||||
|
if (isDrag) {
|
||||||
|
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||||
|
} else if (!moved) {
|
||||||
|
when {
|
||||||
|
maxFingers >= 3 -> showStats = !showStats // in-stream HUD toggle
|
||||||
|
maxFingers == 2 -> { // two-finger tap → right click
|
||||||
|
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||||
|
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||||
|
}
|
||||||
|
else -> { // tap → left click, and arm tap-and-drag
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
NativeBridge.nativeSendPointerButton(handle, 1, true)
|
||||||
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
NativeBridge.nativeSendPointerButton(handle, 1, false)
|
||||||
} else if (!moved && maxFingers >= 3) {
|
lastTapUp = upTime
|
||||||
showStats = !showStats // quick in-stream HUD toggle
|
lastTapX = startX
|
||||||
|
lastTapY = startY
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fun SectionLabel(text: String) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
||||||
* saved hosts) an overflow menu with Forget. Tapping the card connects.
|
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun HostCard(
|
fun HostCard(
|
||||||
@@ -59,6 +59,7 @@ fun HostCard(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onConnect: () -> Unit,
|
onConnect: () -> Unit,
|
||||||
onForget: (() -> Unit)?,
|
onForget: (() -> Unit)?,
|
||||||
|
onRename: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||||
@@ -106,7 +107,7 @@ fun HostCard(
|
|||||||
StatusPill(status)
|
StatusPill(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onForget != null) {
|
if (onForget != null || onRename != null) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||||
@@ -118,6 +119,16 @@ fun HostCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
if (onRename != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename") },
|
||||||
|
onClick = {
|
||||||
|
menu = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onForget != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Forget") },
|
text = { Text("Forget") },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -130,6 +141,7 @@ fun HostCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||||
|
|||||||
@@ -67,6 +67,27 @@ object NativeBridge {
|
|||||||
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
||||||
external fun nativeClose(handle: Long)
|
external fun nativeClose(handle: Long)
|
||||||
|
|
||||||
|
// ---- LAN discovery: mDNS browse of `_punktfunk._udp` in Rust (mdns-sd), polled by Kotlin ----
|
||||||
|
// Replaces NsdManager. The caller holds the Wi-Fi MulticastLock for the browse lifetime; raw
|
||||||
|
// multicast *reception* needs it. See io.unom.punktfunk.kit.discovery.HostDiscovery.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start browsing `_punktfunk._udp` on the LAN. Returns an opaque discovery handle, or `0` on
|
||||||
|
* failure. Pair with exactly one [nativeDiscoveryStop]. Cheap + non-blocking (spawns the mDNS
|
||||||
|
* daemon + a fold thread).
|
||||||
|
*/
|
||||||
|
external fun nativeDiscoveryStart(): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
||||||
|
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||||
|
* cheap (a lock + string build), safe to call on the main thread.
|
||||||
|
*/
|
||||||
|
external fun nativeDiscoveryPoll(handle: Long): String
|
||||||
|
|
||||||
|
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
||||||
|
external fun nativeDiscoveryStop(handle: Long)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
||||||
@@ -108,6 +129,13 @@ object NativeBridge {
|
|||||||
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
|
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
|
||||||
external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int)
|
external fun nativeSendPointerMove(handle: Long, dx: Int, dy: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute mouse position — the host moves the cursor to (x, y) in a [surfaceWidth]×[surfaceHeight]
|
||||||
|
* pixel space (it normalizes against that size and maps into the output region). Touch
|
||||||
|
* "direct pointing": the cursor jumps to the finger. Parity with the Apple client's absolute touch.
|
||||||
|
*/
|
||||||
|
external fun nativeSendPointerAbs(handle: Long, x: Int, y: Int, surfaceWidth: Int, surfaceHeight: Int)
|
||||||
|
|
||||||
/** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */
|
/** One mouse-button transition. button: 1=left 2=middle 3=right 4=X1 5=X2. */
|
||||||
external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean)
|
external fun nativeSendPointerButton(handle: Long, button: Int, down: Boolean)
|
||||||
|
|
||||||
|
|||||||
+84
-134
@@ -1,17 +1,13 @@
|
|||||||
package io.unom.punktfunk.kit.discovery
|
package io.unom.punktfunk.kit.discovery
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.nsd.NsdManager
|
|
||||||
import android.net.nsd.NsdServiceInfo
|
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
|
||||||
private const val TAG = "PunktfunkNsd"
|
private const val TAG = "PunktfunkMdns"
|
||||||
|
|
||||||
/** DNS-SD service type punktfunk hosts advertise (host: `_punktfunk._udp.local.`). */
|
|
||||||
const val PUNKTFUNK_SERVICE_TYPE = "_punktfunk._udp"
|
|
||||||
const val PUNKTFUNK_PROTO = "punktfunk/1"
|
|
||||||
|
|
||||||
/** One resolved host fit for the picker. [key] is the stable dedup id. */
|
/** One resolved host fit for the picker. [key] is the stable dedup id. */
|
||||||
data class DiscoveredHost(
|
data class DiscoveredHost(
|
||||||
@@ -23,165 +19,115 @@ data class DiscoveredHost(
|
|||||||
val pairingRequired: Boolean = false,
|
val pairingRequired: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Parsed TXT fields. Pure — unit-testable without Android (see ParseTxtTest). */
|
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||||
data class TxtFields(
|
private const val FIELD_SEP = '\u001F'
|
||||||
val proto: String?,
|
|
||||||
val fp: String?,
|
|
||||||
val pair: String?,
|
|
||||||
val id: String?,
|
|
||||||
) {
|
|
||||||
val pairingRequired: Boolean get() = pair == "required"
|
|
||||||
val isPunktfunk: Boolean get() = proto == PUNKTFUNK_PROTO
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure TXT parser. NSD hands TXT as a `Map<String, ByteArray?>` (a null/empty value = present-but-
|
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
||||||
* empty key). Decode UTF-8; missing keys are null, never an error.
|
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||||
|
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||||
*/
|
*/
|
||||||
fun parseTxt(attrs: Map<String, ByteArray?>): TxtFields {
|
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||||
fun s(k: String): String? = attrs[k]?.takeIf { it.isNotEmpty() }?.toString(Charsets.UTF_8)
|
val f = record.split(FIELD_SEP)
|
||||||
return TxtFields(proto = s("proto"), fp = s("fp"), pair = s("pair"), id = s("id"))
|
if (f.size < 6) return null
|
||||||
|
val addr = f[2]
|
||||||
|
val port = f[3].toIntOrNull() ?: return null
|
||||||
|
if (addr.isBlank() || port !in 1..65535) return null
|
||||||
|
return DiscoveredHost(
|
||||||
|
key = f[0].ifBlank { "$addr:$port" },
|
||||||
|
name = f[1].ifBlank { addr },
|
||||||
|
host = addr,
|
||||||
|
port = port,
|
||||||
|
fingerprint = f[4].ifBlank { null },
|
||||||
|
pairingRequired = f[5] == "required",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browses `_punktfunk._udp` via NsdManager, resolves each service (the reliable
|
* Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the
|
||||||
* `registerServiceInfoCallback` path on API 34+, legacy `resolveService` on 31–33 where its TXT is
|
* Linux/Windows clients use), exposed over JNI — *not* `NsdManager`, whose per-OEM system daemon
|
||||||
* often empty), and pushes the live host set to [onChange] (invoked on the main thread).
|
* made discovery "mostly broken". [start] spins up the native browse and polls it ~1 Hz on the main
|
||||||
|
* thread, pushing the live host set to [onChange] (also on the main thread, only when it changes);
|
||||||
|
* [stop] tears it down.
|
||||||
*
|
*
|
||||||
* Lifecycle: [start] when the picker appears, [stop] when it leaves / on connect — holds a
|
* We hold a Wi-Fi [WifiManager.MulticastLock] for the browse lifetime — raw multicast *reception*
|
||||||
* MulticastLock while running (an OEM Wi-Fi power-save hedge). Note: the Android emulator's SLIRP
|
* needs it. (The Android emulator's SLIRP NAT drops multicast, so on the emulator discovery starts
|
||||||
* NAT drops multicast, so on the emulator discovery starts but never finds a LAN host.
|
* but never finds a LAN host — same as before; that's the network, not the API.)
|
||||||
*/
|
*/
|
||||||
class HostDiscovery(context: Context) {
|
class HostDiscovery(context: Context) {
|
||||||
private val appCtx = context.applicationContext
|
private val appCtx = context.applicationContext
|
||||||
private val nsd = appCtx.getSystemService(Context.NSD_SERVICE) as NsdManager
|
|
||||||
|
|
||||||
/** Invoked on the main thread whenever the resolved host set changes. */
|
/** Invoked on the main thread whenever the resolved host set changes. */
|
||||||
var onChange: ((List<DiscoveredHost>) -> Unit)? = null
|
var onChange: ((List<DiscoveredHost>) -> Unit)? = null
|
||||||
|
|
||||||
private val resolved = LinkedHashMap<String, DiscoveredHost>() // key -> host
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var multicastLock: WifiManager.MulticastLock? = null
|
private var multicastLock: WifiManager.MulticastLock? = null
|
||||||
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
private var nativeHandle = 0L
|
||||||
private val infoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>() // API 34+ registrations
|
|
||||||
private var running = false
|
private var running = false
|
||||||
|
private var last: List<DiscoveredHost> = emptyList()
|
||||||
|
|
||||||
|
private val poll = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
if (!running) return
|
||||||
|
val hosts = snapshot()
|
||||||
|
if (hosts != last) {
|
||||||
|
last = hosts
|
||||||
|
onChange?.invoke(hosts)
|
||||||
|
}
|
||||||
|
handler.postDelayed(this, POLL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (running) return
|
if (running) return
|
||||||
running = true
|
|
||||||
acquireMulticastLock()
|
acquireMulticastLock()
|
||||||
val listener = makeDiscoveryListener()
|
val h = runCatching { NativeBridge.nativeDiscoveryStart() }
|
||||||
discoveryListener = listener
|
.onFailure { Log.e(TAG, "nativeDiscoveryStart threw", it) }
|
||||||
runCatching {
|
.getOrDefault(0L)
|
||||||
nsd.discoverServices(PUNKTFUNK_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
if (h == 0L) {
|
||||||
}.onFailure {
|
Log.e(TAG, "native mDNS discovery failed to start")
|
||||||
Log.e(TAG, "discoverServices failed", it)
|
releaseMulticastLock()
|
||||||
stop()
|
return
|
||||||
}
|
}
|
||||||
|
nativeHandle = h
|
||||||
|
running = true
|
||||||
|
last = emptyList()
|
||||||
|
handler.post(poll)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
if (!running) return
|
if (!running && nativeHandle == 0L) return
|
||||||
running = false
|
running = false
|
||||||
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
|
handler.removeCallbacks(poll)
|
||||||
discoveryListener = null
|
val h = nativeHandle
|
||||||
if (Build.VERSION.SDK_INT >= 34) {
|
nativeHandle = 0L
|
||||||
for (cb in infoCallbacks) runCatching { nsd.unregisterServiceInfoCallback(cb) }
|
if (h != 0L) runCatching { NativeBridge.nativeDiscoveryStop(h) }
|
||||||
}
|
.onFailure { Log.e(TAG, "nativeDiscoveryStop threw", it) }
|
||||||
infoCallbacks.clear()
|
|
||||||
releaseMulticastLock()
|
releaseMulticastLock()
|
||||||
resolved.clear()
|
last = emptyList()
|
||||||
onChange?.invoke(emptyList())
|
onChange?.invoke(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun snapshot(): List<DiscoveredHost> {
|
||||||
onChange?.invoke(resolved.values.sortedBy { it.name.lowercase() })
|
val h = nativeHandle
|
||||||
}
|
if (h == 0L) return emptyList()
|
||||||
|
// getOrNull (not getOrDefault): the JNI returns a platform String!, so a (near-impossible)
|
||||||
private fun makeDiscoveryListener() = object : NsdManager.DiscoveryListener {
|
// native null is a *success* value here — coalesce it so the main-thread poll can't NPE.
|
||||||
override fun onDiscoveryStarted(type: String) {
|
val blob = runCatching { NativeBridge.nativeDiscoveryPoll(h) }
|
||||||
Log.d(TAG, "discovery started: $type")
|
.onFailure { Log.e(TAG, "nativeDiscoveryPoll threw", it) }
|
||||||
}
|
.getOrNull() ?: ""
|
||||||
override fun onDiscoveryStopped(type: String) {
|
if (blob.isEmpty()) return emptyList()
|
||||||
Log.d(TAG, "discovery stopped: $type")
|
return blob.split('\n')
|
||||||
}
|
.filter { it.isNotBlank() }
|
||||||
override fun onStartDiscoveryFailed(type: String, code: Int) {
|
.mapNotNull { parseHostRecord(it) }
|
||||||
Log.e(TAG, "start discovery failed: $code")
|
.associateBy { it.key } // dedup by stable key (id, or addr:port)
|
||||||
runCatching { nsd.stopServiceDiscovery(this) }
|
.values
|
||||||
}
|
.sortedBy { it.name.lowercase() }
|
||||||
override fun onStopDiscoveryFailed(type: String, code: Int) {
|
|
||||||
Log.e(TAG, "stop discovery failed: $code")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceFound(info: NsdServiceInfo) {
|
|
||||||
Log.d(TAG, "found: ${info.serviceName}")
|
|
||||||
resolve(info)
|
|
||||||
}
|
|
||||||
override fun onServiceLost(info: NsdServiceInfo) {
|
|
||||||
Log.d(TAG, "lost: ${info.serviceName}")
|
|
||||||
// onServiceLost carries no TXT, so drop by the instance-name fallback key only.
|
|
||||||
if (resolved.remove(info.serviceName) != null) publish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolve(found: NsdServiceInfo) {
|
|
||||||
if (Build.VERSION.SDK_INT >= 34) resolveViaCallback(found) else resolveViaLegacy(found)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveViaCallback(found: NsdServiceInfo) {
|
|
||||||
val cb = object : NsdManager.ServiceInfoCallback {
|
|
||||||
override fun onServiceUpdated(info: NsdServiceInfo) = ingest(info)
|
|
||||||
override fun onServiceLost() {}
|
|
||||||
override fun onServiceInfoCallbackRegistrationFailed(code: Int) {
|
|
||||||
Log.e(TAG, "ServiceInfoCallback reg failed: $code")
|
|
||||||
}
|
|
||||||
override fun onServiceInfoCallbackUnregistered() {}
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
nsd.registerServiceInfoCallback(found, appCtx.mainExecutor, cb)
|
|
||||||
infoCallbacks.add(cb)
|
|
||||||
}.onFailure { Log.e(TAG, "registerServiceInfoCallback failed", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveViaLegacy(found: NsdServiceInfo) {
|
|
||||||
// A ResolveListener can't be reused — allocate one per resolve. TXT may be empty pre-34.
|
|
||||||
val listener = object : NsdManager.ResolveListener {
|
|
||||||
override fun onServiceResolved(info: NsdServiceInfo) = ingest(info)
|
|
||||||
override fun onResolveFailed(info: NsdServiceInfo, code: Int) {
|
|
||||||
Log.e(TAG, "resolve failed: $code")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runCatching { nsd.resolveService(found, listener) }
|
|
||||||
.onFailure { Log.e(TAG, "resolveService failed", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION") // info.host is deprecated at API 34 (replaced by hostAddresses)
|
|
||||||
private fun ingest(info: NsdServiceInfo) {
|
|
||||||
val txt = parseTxt(info.attributes)
|
|
||||||
// Reject an incompatible protocol IF the host advertised one; tolerate empty TXT (pre-34).
|
|
||||||
if (txt.proto != null && !txt.isPunktfunk) {
|
|
||||||
Log.d(TAG, "skip non-punktfunk proto=${txt.proto}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val ip = (if (Build.VERSION.SDK_INT >= 34) info.hostAddresses.firstOrNull() else info.host)
|
|
||||||
?.hostAddress ?: return
|
|
||||||
val key = txt.id?.takeIf { it.isNotBlank() } ?: info.serviceName
|
|
||||||
resolved[key] = DiscoveredHost(
|
|
||||||
key = key,
|
|
||||||
name = info.serviceName.removeSuffix("."),
|
|
||||||
host = ip,
|
|
||||||
port = info.port,
|
|
||||||
fingerprint = txt.fp,
|
|
||||||
pairingRequired = txt.pairingRequired,
|
|
||||||
)
|
|
||||||
Log.d(TAG, "resolved: ${resolved[key]}")
|
|
||||||
publish()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun acquireMulticastLock() {
|
private fun acquireMulticastLock() {
|
||||||
val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
val wifi = appCtx.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||||
multicastLock = wifi.createMulticastLock("punktfunk-nsd").apply {
|
multicastLock = wifi.createMulticastLock("punktfunk-mdns").apply {
|
||||||
setReferenceCounted(true)
|
setReferenceCounted(true)
|
||||||
runCatching { acquire() }
|
runCatching { acquire() }
|
||||||
}
|
}
|
||||||
@@ -191,4 +137,8 @@ class HostDiscovery(context: Context) {
|
|||||||
multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
|
multicastLock?.takeIf { it.isHeld }?.let { runCatching { it.release() } }
|
||||||
multicastLock = null
|
multicastLock = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val POLL_MS = 1000L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class KnownHostStore(context: Context) {
|
|||||||
prefs.edit().remove(key(address, port)).apply()
|
prefs.edit().remove(key(address, port)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set a saved host's display name, keeping its pin + paired flag. No-op if not saved. */
|
||||||
|
fun rename(address: String, port: Int, newName: String) {
|
||||||
|
val h = get(address, port) ?: return
|
||||||
|
save(h.copy(name = newName))
|
||||||
|
}
|
||||||
|
|
||||||
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
|
||||||
fun all(): List<KnownHost> =
|
fun all(): List<KnownHost> =
|
||||||
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package io.unom.punktfunk.kit.discovery
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure JVM test of the native-record parser (`key␟name␟addr␟port␟fp␟pair`), the Kotlin half of the
|
||||||
|
* discovery JNI seam. No Android types. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||||
|
*/
|
||||||
|
class ParseRecordTest {
|
||||||
|
private val s = '\u001F' // field separator (must match the Rust side, discovery.rs FIELD_SEP)
|
||||||
|
|
||||||
|
private fun rec(vararg f: String) = f.joinToString(s.toString())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesFullRecord() {
|
||||||
|
val fp = "a".repeat(64)
|
||||||
|
val h = parseHostRecord(rec("host-123", "home-worker-2", "192.168.1.70", "9777", fp, "required"))!!
|
||||||
|
assertEquals("host-123", h.key)
|
||||||
|
assertEquals("home-worker-2", h.name)
|
||||||
|
assertEquals("192.168.1.70", h.host)
|
||||||
|
assertEquals(9777, h.port)
|
||||||
|
assertEquals(fp, h.fingerprint)
|
||||||
|
assertTrue(h.pairingRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun optionalPairingAndEmptyFingerprint() {
|
||||||
|
val h = parseHostRecord(rec("id", "name", "10.0.0.5", "9777", "", "optional"))!!
|
||||||
|
assertNull(h.fingerprint)
|
||||||
|
assertEquals(false, h.pairingRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyKeyFallsBackToAddrPort() {
|
||||||
|
// Host advertised no `id` TXT → the native side leaves the key blank; we synthesize addr:port.
|
||||||
|
val h = parseHostRecord(rec("", "name", "10.0.0.5", "9777", "", "required"))!!
|
||||||
|
assertEquals("10.0.0.5:9777", h.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyNameFallsBackToAddr() {
|
||||||
|
val h = parseHostRecord(rec("k", "", "10.0.0.5", "9777", "", "optional"))!!
|
||||||
|
assertEquals("10.0.0.5", h.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsTooFewFields() {
|
||||||
|
assertNull(parseHostRecord("only${'\u001F'}three${'\u001F'}fields"))
|
||||||
|
assertNull(parseHostRecord(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsBadPortOrAddress() {
|
||||||
|
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "notaport", "", "required")))
|
||||||
|
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "0", "", "required")))
|
||||||
|
assertNull(parseHostRecord(rec("k", "n", "10.0.0.5", "70000", "", "required")))
|
||||||
|
assertNull(parseHostRecord(rec("k", "n", "", "9777", "", "required")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package io.unom.punktfunk.kit.discovery
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/** Pure JVM test of the mDNS TXT parser (no Android types). Run: `./gradlew :kit:testDebugUnitTest`. */
|
|
||||||
class ParseTxtTest {
|
|
||||||
private fun b(s: String): ByteArray = s.toByteArray(Charsets.UTF_8)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesFullRecord() {
|
|
||||||
val fp = "a".repeat(64)
|
|
||||||
val t = parseTxt(
|
|
||||||
mapOf(
|
|
||||||
"proto" to b("punktfunk/1"),
|
|
||||||
"fp" to b(fp),
|
|
||||||
"pair" to b("required"),
|
|
||||||
"id" to b("host-123"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals("punktfunk/1", t.proto)
|
|
||||||
assertEquals(fp, t.fp)
|
|
||||||
assertEquals("host-123", t.id)
|
|
||||||
assertTrue(t.isPunktfunk)
|
|
||||||
assertTrue(t.pairingRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun optionalPairingAndMissingKeys() {
|
|
||||||
val t = parseTxt(mapOf("proto" to b("punktfunk/1"), "pair" to b("optional")))
|
|
||||||
assertFalse(t.pairingRequired)
|
|
||||||
assertNull(t.fp)
|
|
||||||
assertNull(t.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun emptyMapYieldsAllNull() {
|
|
||||||
val t = parseTxt(emptyMap())
|
|
||||||
assertNull(t.proto)
|
|
||||||
assertNull(t.fp)
|
|
||||||
assertNull(t.pair)
|
|
||||||
assertNull(t.id)
|
|
||||||
assertFalse(t.isPunktfunk)
|
|
||||||
assertFalse(t.pairingRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nullAndEmptyValuesTreatedAsAbsent() {
|
|
||||||
// NSD delivers present-but-empty TXT keys as null / empty ByteArray.
|
|
||||||
val t = parseTxt(mapOf("fp" to null, "id" to ByteArray(0), "proto" to b("punktfunk/1")))
|
|
||||||
assertNull(t.fp)
|
|
||||||
assertNull(t.id)
|
|
||||||
assertTrue(t.isPunktfunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nonPunktfunkProtoIsNotAccepted() {
|
|
||||||
assertFalse(parseTxt(mapOf("proto" to b("moonlight/7"))).isPunktfunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,12 @@ crate-type = ["cdylib"]
|
|||||||
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
||||||
jni = "0.21"
|
jni = "0.21"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
# LAN host discovery: browse the host's `_punktfunk._udp` mDNS advert — the SAME crate + service the
|
||||||
|
# Linux/Windows clients use (`clients/linux/src/discovery.rs`), replacing Android's per-OEM
|
||||||
|
# `NsdManager` system daemon with one tested browse path. Pure Rust (socket2/if-addrs/mio), so it
|
||||||
|
# cross-compiles to the Android targets AND builds on the host (the JNI seam links into
|
||||||
|
# `cargo build --workspace`). Kotlin keeps only the Wi-Fi `MulticastLock` + permission UX.
|
||||||
|
mdns-sd = "0.20"
|
||||||
|
|
||||||
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
|
# Android-only deps. Gated so `cargo build --workspace` on the Linux/macOS dev boxes + CI still
|
||||||
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
|
# compiles this crate (as a host cdylib) — the Android-framework glue (logging now; AMediaCodec via
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
//! LAN host discovery over mDNS, in Rust via `mdns-sd` — the same crate + service type the
|
||||||
|
//! Linux/Windows clients use (`clients/linux/src/discovery.rs`), exposed to Kotlin over JNI.
|
||||||
|
//!
|
||||||
|
//! Why not `NsdManager`: that API delegates to a per-OEM system mDNS daemon whose reliability
|
||||||
|
//! varies wildly (the Android client's discovery was "mostly broken"). Browsing in our own Rust
|
||||||
|
//! core — the crate is already linked for the whole protocol — gives one tested code path across
|
||||||
|
//! every desktop + mobile client and removes the system-daemon dependency. Kotlin still holds the
|
||||||
|
//! Wi-Fi `MulticastLock` for the browse lifetime (raw multicast *reception* needs it) and owns the
|
||||||
|
//! permission UX; this module owns the socket + resolve.
|
||||||
|
//!
|
||||||
|
//! Shape: [`Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart`] spins up a
|
||||||
|
//! [`ServiceDaemon`] browsing `_punktfunk._udp.local.` on a background thread that folds
|
||||||
|
//! resolve/remove events into a shared map; Kotlin polls `nativeDiscoveryPoll` ~1 Hz for a
|
||||||
|
//! newline-joined snapshot and calls `nativeDiscoveryStop` to tear it down. Polling (not a JVM
|
||||||
|
//! callback) mirrors `nativeVideoStats`: no `AttachCurrentThread`/global-ref lifecycle to get
|
||||||
|
//! wrong, and 1 Hz is plenty for a host picker.
|
||||||
|
|
||||||
|
use crate::session::jni_guard;
|
||||||
|
use jni::objects::JObject;
|
||||||
|
use jni::sys::jlong;
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
|
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||||
|
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||||
|
/// Wire protocol id in the `proto` TXT record; a host advertising anything else is skipped.
|
||||||
|
const PROTO: &str = "punktfunk/1";
|
||||||
|
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||||
|
const FIELD_SEP: char = '\u{1f}';
|
||||||
|
|
||||||
|
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
||||||
|
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||||
|
/// every field so no value can break it.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
struct Host {
|
||||||
|
key: String,
|
||||||
|
name: String,
|
||||||
|
addr: String,
|
||||||
|
port: u16,
|
||||||
|
fp: String,
|
||||||
|
pair: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Host {
|
||||||
|
fn encode(&self) -> String {
|
||||||
|
// mDNS instance labels + TXT values are arbitrary UTF-8 from an UNauthenticated source, so
|
||||||
|
// strip the field/record separators: a rogue advert that smuggled '\n'/U+001F could otherwise
|
||||||
|
// inject or suppress picker rows. (Trust is still gated on connect — this only protects the
|
||||||
|
// list's integrity.)
|
||||||
|
fn clean(s: &str) -> String {
|
||||||
|
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||||
|
clean(&self.key),
|
||||||
|
clean(&self.name),
|
||||||
|
clean(&self.addr),
|
||||||
|
self.port,
|
||||||
|
clean(&self.fp),
|
||||||
|
clean(&self.pair),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running browse behind the `jlong` handle: the daemon, the shared resolved-host map keyed by
|
||||||
|
/// mDNS fullname (stable across re-announce and present on both resolve *and* remove — which fixes
|
||||||
|
/// the old `NsdManager` key mismatch that leaked stale hosts), and the event-fold thread.
|
||||||
|
struct Discovery {
|
||||||
|
daemon: ServiceDaemon,
|
||||||
|
hosts: Arc<Mutex<HashMap<String, Host>>>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Discovery {
|
||||||
|
fn start() -> Option<Discovery> {
|
||||||
|
let daemon = match ServiceDaemon::new() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("mDNS daemon failed — discovery disabled: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rx = match daemon.browse(SERVICE_TYPE) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("mDNS browse failed — discovery disabled: {e}");
|
||||||
|
let _ = daemon.shutdown();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let hosts: Arc<Mutex<HashMap<String, Host>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let map = hosts.clone();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pf-mdns".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Exits when the daemon is shut down (the browse channel closes → recv errors).
|
||||||
|
while let Ok(event) = rx.recv() {
|
||||||
|
match event {
|
||||||
|
ServiceEvent::ServiceResolved(info) => {
|
||||||
|
if let Some(host) = resolve(&info) {
|
||||||
|
map.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(info.get_fullname().to_string(), host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||||
|
map.lock().unwrap().remove(&fullname);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let thread = match spawned {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
// The daemon thread + bound :5353 socket outlive a dropped handle (no Drop impl), so
|
||||||
|
// shut it down explicitly — same cleanup as the browse-failure path above.
|
||||||
|
log::error!("mDNS fold thread spawn failed: {e}");
|
||||||
|
let _ = daemon.shutdown();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
log::info!("native mDNS discovery started ({SERVICE_TYPE})");
|
||||||
|
Some(Discovery {
|
||||||
|
daemon,
|
||||||
|
hosts,
|
||||||
|
thread: Some(thread),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current resolved-host set, newline-joined (empty string = none). Sorted for a stable order
|
||||||
|
/// across polls; Kotlin re-sorts by display name.
|
||||||
|
fn snapshot(&self) -> String {
|
||||||
|
let mut records: Vec<String> = self
|
||||||
|
.hosts
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.map(Host::encode)
|
||||||
|
.collect();
|
||||||
|
records.sort();
|
||||||
|
records.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(mut self) {
|
||||||
|
let _ = self.daemon.shutdown(); // closes the browse channel → the fold thread exits
|
||||||
|
if let Some(t) = self.thread.take() {
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`Host`] from a resolved mDNS record, or `None` if it isn't a usable punktfunk host
|
||||||
|
/// (incompatible advertised proto, or no IPv4 address). IPv4 only on purpose: the core dials with
|
||||||
|
/// `format!("{host}:{port}").parse::<SocketAddr>()`, which can't parse a bare/scoped IPv6 literal
|
||||||
|
/// (it needs the `[addr%scope]:port` form), so surfacing a v6-only host would present a card that
|
||||||
|
/// fails on every tap. Dropping it shows the honest "not found" instead.
|
||||||
|
fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||||
|
let val = |k: &str| info.get_property_val_str(k).unwrap_or("").to_string();
|
||||||
|
let proto = val("proto");
|
||||||
|
if !proto.is_empty() && proto != PROTO {
|
||||||
|
return None; // some other DNS-SD service sharing the type — ignore
|
||||||
|
}
|
||||||
|
let addr = info
|
||||||
|
.get_addresses_v4()
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.map(|a| a.to_string())?;
|
||||||
|
let id = val("id");
|
||||||
|
let fullname = info.get_fullname();
|
||||||
|
Some(Host {
|
||||||
|
key: if id.is_empty() {
|
||||||
|
fullname.to_string()
|
||||||
|
} else {
|
||||||
|
id
|
||||||
|
},
|
||||||
|
name: fullname.split('.').next().unwrap_or("?").to_string(),
|
||||||
|
addr,
|
||||||
|
port: info.get_port(),
|
||||||
|
fp: val("fp"),
|
||||||
|
pair: val("pair"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeDiscoveryStart(): Long` — start browsing `_punktfunk._udp`; returns an opaque
|
||||||
|
/// handle, or `0` on failure (logged). Pair with exactly one [`nativeDiscoveryStop`]. Kotlin must
|
||||||
|
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
|
||||||
|
///
|
||||||
|
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
) -> jlong {
|
||||||
|
jni_guard(0, || match Discovery::start() {
|
||||||
|
Some(d) => Box::into_raw(Box::new(d)) as jlong,
|
||||||
|
None => 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||||
|
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
|
||||||
|
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
handle: jlong,
|
||||||
|
) -> jni::sys::jstring {
|
||||||
|
jni_guard(std::ptr::null_mut(), || {
|
||||||
|
let out = if handle == 0 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
// SAFETY: live handle per the start/stop contract — Kotlin owns the lifecycle and never
|
||||||
|
// polls after stop (it nulls the handle first).
|
||||||
|
let d = unsafe { &*(handle as *const Discovery) };
|
||||||
|
d.snapshot()
|
||||||
|
};
|
||||||
|
match env.new_string(out) {
|
||||||
|
Ok(s) => s.into_raw(),
|
||||||
|
Err(_) => std::ptr::null_mut(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeDiscoveryStop(handle)` — stop the browse, shut the daemon down and join its
|
||||||
|
/// thread. No-op on `0`.
|
||||||
|
///
|
||||||
|
/// # Safety contract
|
||||||
|
/// `handle` must be `0` or a live handle from [`nativeDiscoveryStart`], stopped exactly once and not
|
||||||
|
/// concurrently with [`nativeDiscoveryPoll`] (Kotlin owns this; all calls are on the main thread).
|
||||||
|
///
|
||||||
|
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
|
||||||
|
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
jni_guard((), || {
|
||||||
|
if handle != 0 {
|
||||||
|
// SAFETY: live handle from nativeDiscoveryStart, stopped exactly once per the contract.
|
||||||
|
let d = unsafe { Box::from_raw(handle as *mut Discovery) };
|
||||||
|
d.stop();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_round_trips_all_fields_with_unit_separator() {
|
||||||
|
let h = Host {
|
||||||
|
key: "host-123".into(),
|
||||||
|
name: "home-worker-2".into(),
|
||||||
|
addr: "192.168.1.70".into(),
|
||||||
|
port: 9777,
|
||||||
|
fp: "ab".repeat(32),
|
||||||
|
pair: "required".into(),
|
||||||
|
};
|
||||||
|
let encoded = h.encode();
|
||||||
|
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||||
|
assert_eq!(fields.len(), 6);
|
||||||
|
assert_eq!(fields[0], "host-123");
|
||||||
|
assert_eq!(fields[1], "home-worker-2");
|
||||||
|
assert_eq!(fields[2], "192.168.1.70");
|
||||||
|
assert_eq!(fields[3], "9777");
|
||||||
|
assert_eq!(fields[4], "ab".repeat(32));
|
||||||
|
assert_eq!(fields[5], "required");
|
||||||
|
assert!(
|
||||||
|
!encoded.contains('\n'),
|
||||||
|
"a record must never contain the record separator"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||||
|
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
||||||
|
// them so the snapshot stays exactly one record of exactly six fields.
|
||||||
|
let h = Host {
|
||||||
|
key: "k\u{1f}injected".into(),
|
||||||
|
name: "evil\nhost\r".into(),
|
||||||
|
addr: "10.0.0.5".into(),
|
||||||
|
port: 9777,
|
||||||
|
fp: "ab\u{1f}cd".into(),
|
||||||
|
pair: "required\n".into(),
|
||||||
|
};
|
||||||
|
let encoded = h.encode();
|
||||||
|
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
||||||
|
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||||
|
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||||
|
assert_eq!(fields[0], "kinjected");
|
||||||
|
assert_eq!(fields[1], "evilhost");
|
||||||
|
assert_eq!(fields[4], "abcd");
|
||||||
|
assert_eq!(fields[5], "required");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,17 @@
|
|||||||
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
|
//! Architecture: the **Rust-heavy** client model (like `punktfunk-client-linux`, *not* the
|
||||||
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
|
//! thin-native-over-C-ABI Apple model). This `cdylib` links `punktfunk-core` directly and drives
|
||||||
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
|
//! the whole `punktfunk/1` protocol through [`punktfunk_core::client::NativeClient`]; Kotlin owns
|
||||||
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture,
|
//! only the Android-framework surface (Compose UI, `SurfaceView` lifecycle, input capture, the
|
||||||
//! `NsdManager` discovery, Keystore). The JNI seam below is the one place the two languages meet.
|
//! Wi-Fi `MulticastLock` + permission UX, Keystore). The JNI seam below is the one place the two
|
||||||
|
//! languages meet.
|
||||||
//!
|
//!
|
||||||
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
|
//! Why Rust-heavy: Kotlin cannot `import` the cbindgen C header the way Swift can, so a native
|
||||||
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
|
//! bridge is unavoidable. Writing it in Rust lets the Android client reuse the Linux client's
|
||||||
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
|
//! orchestration verbatim — audio jitter ring, the VK keymap inverse, latency/skew math, the
|
||||||
//! input capture state machine, trust/pairing logic — instead of re-porting it into Kotlin.
|
//! input capture state machine, trust/pairing logic, **mDNS discovery** ([`discovery`], the same
|
||||||
|
//! `mdns-sd` browse the Linux/Windows clients use) — instead of re-porting it into Kotlin. Kotlin
|
||||||
|
//! keeps only the Android-framework surface it must (Compose UI, `SurfaceView`, input capture, the
|
||||||
|
//! Wi-Fi `MulticastLock` + permission UX, Keystore identity).
|
||||||
//!
|
//!
|
||||||
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
|
//! JNI symbols map to `io.unom.punktfunk.kit.NativeBridge` in the `:kit` Gradle module
|
||||||
//! (`clients/android`). The current surface is the scaffold's native-link proof
|
//! (`clients/android`). The current surface is the scaffold's native-link proof
|
||||||
@@ -25,6 +29,9 @@ use jni::JNIEnv;
|
|||||||
mod audio;
|
mod audio;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod decode;
|
mod decode;
|
||||||
|
// Ungated: pure `mdns-sd` + `jni`, so the browse + its JNI seam link into the host workspace build
|
||||||
|
// (and its unit test runs there) exactly like `session`/`stats`. Kotlin only ever calls it on device.
|
||||||
|
mod discovery;
|
||||||
mod feedback;
|
mod feedback;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod mic;
|
mod mic;
|
||||||
|
|||||||
@@ -557,6 +557,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeSendPointerAbs(handle, x, y, surfaceWidth, surfaceHeight)` — absolute cursor
|
||||||
|
/// position: the host moves the pointer to `x`/`y` in a `surfaceWidth`×`surfaceHeight` pixel space,
|
||||||
|
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
||||||
|
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
||||||
|
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
x: jint,
|
||||||
|
y: jint,
|
||||||
|
surface_width: jint,
|
||||||
|
surface_height: jint,
|
||||||
|
) {
|
||||||
|
if handle == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
let w = (surface_width.max(0) as u32) & 0xffff;
|
||||||
|
let ht = (surface_height.max(0) as u32) & 0xffff;
|
||||||
|
let _ = h.client.send_input(&InputEvent {
|
||||||
|
kind: InputKind::MouseMoveAbs,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: 0,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
flags: (w << 16) | ht,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
||||||
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
|
|||||||
+53
-1
@@ -174,6 +174,58 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
|||||||
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
||||||
passes the dev autoconnect env through).
|
passes the dev autoconnect env through).
|
||||||
|
|
||||||
|
## App Store screenshots
|
||||||
|
|
||||||
|
Automated, faithful screenshots of the real UI for App Store Connect — one set per platform at
|
||||||
|
exactly the accepted pixel sizes. Driver: **`tools/screenshots.sh`**.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/screenshots.sh all # macOS + (if full Xcode) iOS, iPadOS, tvOS → ./screenshots
|
||||||
|
tools/screenshots.sh macos # just macOS
|
||||||
|
OUT=~/Desktop/shots tools/screenshots.sh ios ipad tvos
|
||||||
|
PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame behind the hero
|
||||||
|
```
|
||||||
|
|
||||||
|
How it works: the app has a DEBUG-only **shot mode** (`Sources/PunktfunkClient/Screenshots/`).
|
||||||
|
Launched with `PUNKTFUNK_SHOT_SCENE=<name>` it renders **one** mock-populated screen full-bleed
|
||||||
|
(`ScreenshotHostView`) instead of `ContentView`, then the OS screenshots the *real, fully-rendered*
|
||||||
|
window — `screencapture` on macOS, `xcrun simctl io booted screenshot` on the Simulators. The five
|
||||||
|
scenes (`ShotScenes.all`): `01-stream` (the stream hero — a synthetic frame + the glass HUD, since
|
||||||
|
`StreamView` needs a live connection), `02-hosts`, `03-pair`, `04-trust`, `05-settings`. Mock data
|
||||||
|
is in `ShotMock`; nothing touches a host.
|
||||||
|
|
||||||
|
Output pixels are App Store Connect's required/largest sizes (Apple auto-derives the smaller ones):
|
||||||
|
`mac` 2880×1800 · `iphone-6.9` 1320×2868 (hero 2868×1320) · `ipad-13` 2064×2752 (hero 2752×2064) ·
|
||||||
|
`appletv` 1920×1080.
|
||||||
|
|
||||||
|
Why not `ImageRenderer` (the obvious offscreen route)? It can't rasterize this app's chrome —
|
||||||
|
`NavigationStack`, `Form`/`TabView`, and Liquid-Glass/`NSVisualEffect` materials all render black or
|
||||||
|
SwiftUI's "can't render" placeholder. Capturing the live window/Simulator avoids that entirely.
|
||||||
|
|
||||||
|
Requirements / gotchas:
|
||||||
|
- **macOS**: only the Swift toolchain is needed, **plus a one-time Screen Recording grant** for
|
||||||
|
your terminal (System Settings → Privacy & Security → Screen Recording) — without it
|
||||||
|
`screencapture -l` fails with "could not create image from window". (A no-permission fallback,
|
||||||
|
`PUNKTFUNK_SHOT_SELFCAPTURE=<dir>`, uses `cacheDisplay` — but it omits material blur and can't
|
||||||
|
read `ScrollView` content, so it's for quick checks, not submission.)
|
||||||
|
- **iOS/iPadOS/tvOS**: needs **full Xcode** (xcodebuild + Simulators), not just Command Line Tools,
|
||||||
|
and the matching device Simulators installed (iPhone 16 Pro Max, iPad Pro 13", Apple TV). Run it
|
||||||
|
on a full-Xcode Mac (e.g. the `macos-arm64` CI mini).
|
||||||
|
- The hero defaults to a synthetic synthwave frame — set `PUNKTFUNK_SHOT_HERO` to a real captured
|
||||||
|
frame for a production-quality lead screenshot.
|
||||||
|
|
||||||
|
**CI**: the `apple` workflow's **`screenshots`** job runs on the `macos-arm64` runner on every main
|
||||||
|
push + manual dispatch (skipped on PRs), and attaches the result as a single zip artifact,
|
||||||
|
**`punktfunk-appstore-screenshots`** (download it from the run's Artifacts; `upload-artifact@v3` —
|
||||||
|
Gitea's backend rejects v4). It captures the two **required iOS sizes — iPhone 6.9" + iPad 13"** —
|
||||||
|
on the Simulator (auto-creating the device if the runner lacks it), and is isolated from the
|
||||||
|
build/test job so a capture hiccup never reds the build.
|
||||||
|
|
||||||
|
**macOS and tvOS are NOT in CI**, by design: the self-hosted runner is **headless** (no
|
||||||
|
window-server session), so the macOS window capture can't run there, and tvOS needs the Tier-3
|
||||||
|
build-std slice. Generate those on a GUI Mac: `tools/screenshots.sh macos tvos`. (If the runner is
|
||||||
|
ever switched to a logged-in GUI session, re-adding macOS to the job's capture step is one line.)
|
||||||
|
|
||||||
## Notes for whoever picks this up next
|
## Notes for whoever picks this up next
|
||||||
|
|
||||||
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
|
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
|
||||||
@@ -309,4 +361,4 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
|||||||
- Mid-stream renegotiation (resolution change without reconnect) is designed-for but not
|
- Mid-stream renegotiation (resolution change without reconnect) is designed-for but not
|
||||||
implemented (the Welcome is one-shot today).
|
implemented (the Welcome is one-shot today).
|
||||||
- Host-side gamepad injection needs `/dev/uinput` access on the box (udev rule from
|
- Host-side gamepad injection needs `/dev/uinput` access on the box (udev rule from
|
||||||
`docs/linux-setup.md`).
|
`design/linux-setup.md`).
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
// DEBUG-only controller test panel, reached from Settings → Controllers → "Test Controller…".
|
||||||
|
// It shows the live input of the active controller and lets you fire the host→client feedback
|
||||||
|
// channels — rumble, DualSense adaptive triggers, lightbar, player LEDs — straight at the
|
||||||
|
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
||||||
|
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
||||||
|
//
|
||||||
|
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
||||||
|
// layout); macOS + iOS/iPadOS cover the validation need.
|
||||||
|
|
||||||
|
#if DEBUG && !os(tvOS)
|
||||||
|
import GameController
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ControllerTestView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
@StateObject private var tester = ControllerTester()
|
||||||
|
|
||||||
|
@State private var heavyOn = false
|
||||||
|
@State private var lightOn = false
|
||||||
|
@State private var intensity = 0.75
|
||||||
|
@State private var triggerTarget = TriggerTarget.both
|
||||||
|
@State private var playerLED = -1
|
||||||
|
|
||||||
|
private enum TriggerTarget: String, CaseIterable, Identifiable {
|
||||||
|
case left = "L2", right = "R2", both = "Both"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TriggerDemo: Identifiable {
|
||||||
|
let label: String
|
||||||
|
let effect: DualSenseTriggerEffect
|
||||||
|
var id: String { label }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let triggerDemos: [TriggerDemo] = [
|
||||||
|
.init(label: "Off", effect: .off),
|
||||||
|
.init(label: "Resistance", effect: .feedback(start: 0.3, strength: 0.7)),
|
||||||
|
.init(label: "Weapon", effect: .weapon(start: 0.4, end: 0.7, strength: 0.9)),
|
||||||
|
.init(label: "Vibration", effect: .vibration(start: 0.1, amplitude: 0.8, frequency: 0.5)),
|
||||||
|
.init(label: "Bow", effect: .slope(start: 0.2, end: 0.9, startStrength: 0.2, endStrength: 0.9)),
|
||||||
|
]
|
||||||
|
|
||||||
|
// (display name, hardware colour, swatch colour)
|
||||||
|
private static let lightSwatches: [(String, GCColor, Color)] = [
|
||||||
|
("Red", GCColor(red: 1, green: 0, blue: 0), .red),
|
||||||
|
("Green", GCColor(red: 0, green: 1, blue: 0), .green),
|
||||||
|
("Blue", GCColor(red: 0, green: 0.2, blue: 1), .blue),
|
||||||
|
("White", GCColor(red: 1, green: 1, blue: 1), .white),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Test Controller").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if let active = gamepads.active {
|
||||||
|
header(active)
|
||||||
|
inputCard
|
||||||
|
rumbleCard()
|
||||||
|
triggerCard(active)
|
||||||
|
extrasCard(active)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No controller",
|
||||||
|
systemImage: "gamecontroller",
|
||||||
|
description: Text("Connect a controller and pick it under "
|
||||||
|
+ "Settings → Controllers → Use controller."))
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 220)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 420, minHeight: 540)
|
||||||
|
.onAppear { tester.target(gamepads.active?.controller) }
|
||||||
|
.onDisappear { tester.stop() }
|
||||||
|
.onChange(of: gamepads.active?.id) { _, _ in
|
||||||
|
heavyOn = false
|
||||||
|
lightOn = false
|
||||||
|
playerLED = -1
|
||||||
|
tester.target(gamepads.active?.controller)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Header
|
||||||
|
|
||||||
|
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(c.name).font(.headline)
|
||||||
|
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Input
|
||||||
|
|
||||||
|
private var inputCard: some View {
|
||||||
|
card("Input") {
|
||||||
|
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
||||||
|
// capture is disturbed.
|
||||||
|
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
||||||
|
if let gp = gamepads.active?.controller.extendedGamepad {
|
||||||
|
inputReadout(gp, controller: gamepads.active?.controller)
|
||||||
|
} else {
|
||||||
|
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(alignment: .top, spacing: 20) {
|
||||||
|
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||||
|
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
||||||
|
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||||
|
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
triggerBar("L2", value: g.leftTrigger.value)
|
||||||
|
triggerBar("R2", value: g.rightTrigger.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttonGrid(g)
|
||||||
|
if let tp = Self.touchpad(g) {
|
||||||
|
touchpadView(tp)
|
||||||
|
}
|
||||||
|
if let m = controller?.motion {
|
||||||
|
motionReadout(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
Circle().stroke(Color.secondary.opacity(0.3))
|
||||||
|
Circle()
|
||||||
|
.fill(pressed ? Color.accentColor : Color.secondary)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
||||||
|
}
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerBar(_ label: String, value: Float) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(label).font(.caption2.monospaced()).frame(width: 22, alignment: .leading)
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Color.secondary.opacity(0.15))
|
||||||
|
Capsule().fill(Color.accentColor).frame(width: geo.size.width * CGFloat(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 10)
|
||||||
|
Text(mag(value)).font(.caption2.monospaced()).frame(width: 34, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(width: 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
||||||
|
var items: [(String, Bool)] = [
|
||||||
|
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||||
|
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||||
|
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||||
|
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||||
|
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||||
|
("Menu", g.buttonMenu.isPressed),
|
||||||
|
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||||
|
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||||
|
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||||
|
]
|
||||||
|
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
||||||
|
return LazyVGrid(
|
||||||
|
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
||||||
|
) {
|
||||||
|
ForEach(items.indices, id: \.self) { i in
|
||||||
|
Text(items[i].0)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(items[i].1 ? Color.accentColor : Color.secondary.opacity(0.15)))
|
||||||
|
.foregroundStyle(items[i].1 ? Color.white : Color.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touchpadView(
|
||||||
|
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||||
|
button: GCControllerButtonInput)
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||||
|
fingerDot(tp.primary, color: .accentColor)
|
||||||
|
fingerDot(tp.secondary, color: .orange)
|
||||||
|
}
|
||||||
|
.frame(width: 150, height: 74)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
||||||
|
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||||
|
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
||||||
|
return Circle().fill(color).frame(width: 10, height: 10)
|
||||||
|
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
||||||
|
.opacity(active ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func motionReadout(_ m: GCMotion) -> some View {
|
||||||
|
let a = Self.totalAccel(m)
|
||||||
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Motion").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||||
|
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Rumble
|
||||||
|
|
||||||
|
private func rumbleCard() -> some View {
|
||||||
|
card("Rumble") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Picker("Strength", selection: $intensity) {
|
||||||
|
Text("25%").tag(0.25)
|
||||||
|
Text("50%").tag(0.5)
|
||||||
|
Text("75%").tag(0.75)
|
||||||
|
Text("100%").tag(1.0)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||||
|
Toggle("Light motor (right)", isOn: $lightOn)
|
||||||
|
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||||
|
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||||
|
+ "can't reach its motors on macOS).")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||||
|
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||||
|
.onChange(of: intensity) { _, _ in applyRumble() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyRumble() {
|
||||||
|
tester.rumble(low: heavyOn ? Float(intensity) : 0, high: lightOn ? Float(intensity) : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Adaptive triggers
|
||||||
|
|
||||||
|
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||||
|
card("Adaptive triggers") {
|
||||||
|
if c.hasAdaptiveTriggers {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Picker("Apply to", selection: $triggerTarget) {
|
||||||
|
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8
|
||||||
|
) {
|
||||||
|
ForEach(Self.triggerDemos) { demo in
|
||||||
|
Button(demo.label) { applyTrigger(demo.effect) }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Adaptive triggers need a DualSense.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyTrigger(_ e: DualSenseTriggerEffect) {
|
||||||
|
switch triggerTarget {
|
||||||
|
case .left: tester.applyTrigger(e, right: false)
|
||||||
|
case .right: tester.applyTrigger(e, right: true)
|
||||||
|
case .both:
|
||||||
|
tester.applyTrigger(e, right: false)
|
||||||
|
tester.applyTrigger(e, right: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Lightbar + player LED
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||||
|
if c.hasLight {
|
||||||
|
card("Lightbar & player LED") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(Self.lightSwatches.indices, id: \.self) { i in
|
||||||
|
Button { tester.setLight(Self.lightSwatches[i].1) } label: {
|
||||||
|
Circle().fill(Self.lightSwatches[i].2)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.overlay(Circle().stroke(Color.secondary.opacity(0.4)))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Button("Off") { tester.setLight(nil) }.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
Picker("Player LED", selection: $playerLED) {
|
||||||
|
Text("Off").tag(-1)
|
||||||
|
Text("1").tag(0)
|
||||||
|
Text("2").tag(1)
|
||||||
|
Text("3").tag(2)
|
||||||
|
Text("4").tag(3)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: playerLED) { _, v in
|
||||||
|
tester.setPlayerIndex(GCControllerPlayerIndex(rawValue: v) ?? .indexUnset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
private func card<Content: View>(
|
||||||
|
_ title: String, @ViewBuilder _ content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(title).font(.subheadline.weight(.semibold))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sgn(_ v: Float) -> String { String(format: "%+.2f", v) }
|
||||||
|
private func mag(_ v: Float) -> String { String(format: "%.2f", v) }
|
||||||
|
|
||||||
|
/// The touchpad surface of a PlayStation pad — `GCDualSenseGamepad` and `GCDualShockGamepad`
|
||||||
|
/// don't share a touchpad type, so downcast either. `nil` for any other controller.
|
||||||
|
private static func touchpad(
|
||||||
|
_ g: GCExtendedGamepad
|
||||||
|
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||||
|
button: GCControllerButtonInput)? {
|
||||||
|
if let ds = g as? GCDualSenseGamepad {
|
||||||
|
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
||||||
|
}
|
||||||
|
if let ds4 = g as? GCDualShockGamepad {
|
||||||
|
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total acceleration in g: gravity + user when the pad splits them, else the raw vector.
|
||||||
|
private static func totalAccel(_ m: GCMotion) -> (Double, Double, Double) {
|
||||||
|
if m.hasGravityAndUserAcceleration {
|
||||||
|
return (m.gravity.x + m.userAcceleration.x,
|
||||||
|
m.gravity.y + m.userAcceleration.y,
|
||||||
|
m.gravity.z + m.userAcceleration.z)
|
||||||
|
}
|
||||||
|
return (m.acceleration.x, m.acceleration.y, m.acceleration.z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -14,8 +14,19 @@ struct PunktfunkClientApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup("Punktfunk") {
|
WindowGroup("Punktfunk") {
|
||||||
|
#if DEBUG
|
||||||
|
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||||
|
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||||
|
// the whole path is absent from Release builds.
|
||||||
|
if let scene = ScreenshotMode.requestedScene {
|
||||||
|
ScreenshotHostView(scene: scene)
|
||||||
|
} else {
|
||||||
ContentView()
|
ContentView()
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
ContentView()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// App Store screenshot harness — device catalog.
|
||||||
|
//
|
||||||
|
// The harness captures the REAL running UI (not an offscreen ImageRenderer snapshot, which can't
|
||||||
|
// rasterize NavigationStack / Form / Liquid-Glass — they come out black). The app is launched in
|
||||||
|
// "shot mode" (PUNKTFUNK_SHOT_SCENE=<name>, see ScreenshotHost) showing one mock-populated scene
|
||||||
|
// full-bleed, and the OS screenshots it: `xcrun simctl io booted screenshot` on the iOS/tvOS
|
||||||
|
// simulators (native pixels = the exact App Store size), `screencapture` for the mac window.
|
||||||
|
// tools/screenshots.sh drives it. DEBUG-only — none of this ships in Release.
|
||||||
|
//
|
||||||
|
// This catalog records the target App Store sizes; on Apple platforms only the mac size is read
|
||||||
|
// at runtime (to size the capture window) — the simulator IS the device, so iOS/tvOS pixels are
|
||||||
|
// whatever the booted device is.
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
enum ShotOrientation { case natural, portrait, landscape }
|
||||||
|
|
||||||
|
/// A target App Store canvas: a natural-orientation pixel size + backing scale.
|
||||||
|
struct ShotDevice {
|
||||||
|
let id: String
|
||||||
|
let naturalWidth: Int
|
||||||
|
let naturalHeight: Int
|
||||||
|
let scale: CGFloat
|
||||||
|
|
||||||
|
func pixels(_ o: ShotOrientation) -> (w: Int, h: Int) {
|
||||||
|
let long = max(naturalWidth, naturalHeight)
|
||||||
|
let short = min(naturalWidth, naturalHeight)
|
||||||
|
switch o {
|
||||||
|
case .natural: return (naturalWidth, naturalHeight)
|
||||||
|
case .portrait: return (short, long)
|
||||||
|
case .landscape: return (long, short)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logical point size (pixels / scale) — used to size the mac capture window so that a
|
||||||
|
/// `screencapture` on a 2× display yields exactly `pixels(_:)`.
|
||||||
|
func points(_ o: ShotOrientation) -> CGSize {
|
||||||
|
let (w, h) = pixels(o)
|
||||||
|
return CGSize(width: CGFloat(w) / scale, height: CGFloat(h) / scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mac: 2880×1800 (16:10 Retina) — an accepted size; on a 1× display the window capture is
|
||||||
|
/// 1440×900, also accepted.
|
||||||
|
static let mac = ShotDevice(id: "mac", naturalWidth: 2880, naturalHeight: 1800, scale: 2)
|
||||||
|
|
||||||
|
/// iPhone 6.9" (required) — for reference / the driver script's simulator choice.
|
||||||
|
static let iphone69 = ShotDevice(id: "iphone-6.9", naturalWidth: 1320, naturalHeight: 2868,
|
||||||
|
scale: 3)
|
||||||
|
/// iPad 13" (required).
|
||||||
|
static let ipad13 = ShotDevice(id: "ipad-13", naturalWidth: 2064, naturalHeight: 2752,
|
||||||
|
scale: 2)
|
||||||
|
/// Apple TV (always landscape).
|
||||||
|
static let appleTV = ShotDevice(id: "appletv", naturalWidth: 1920, naturalHeight: 1080,
|
||||||
|
scale: 1)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// App Store screenshot harness — the in-app "shot mode" root.
|
||||||
|
//
|
||||||
|
// Launched with PUNKTFUNK_SHOT_SCENE=<name> (one of ShotScenes.all), the app shows that single
|
||||||
|
// mock-populated scene full-bleed instead of ContentView, so the OS can screenshot the REAL,
|
||||||
|
// fully-rendered UI (materials, NavigationStack, glass — all the things ImageRenderer can't
|
||||||
|
// rasterize offscreen). tools/screenshots.sh drives one launch per scene per device.
|
||||||
|
//
|
||||||
|
// Capture per platform:
|
||||||
|
// • iOS / tvOS simulator → `xcrun simctl io booted screenshot` (native pixels = exact size).
|
||||||
|
// • macOS → `screencapture -l<windowID>` of the borderless capture window (the configurator
|
||||||
|
// prints `PF_SHOT_WINDOW=<id>`), or the no-permission self-capture fallback
|
||||||
|
// (PUNKTFUNK_SHOT_SELFCAPTURE=<dir> → cacheDisplay; renders the real hierarchy but, like all
|
||||||
|
// non-window-server capture, omits material blur).
|
||||||
|
//
|
||||||
|
// Every screen prints `PF_SHOT_READY scene=<name>` to stdout once it has settled, so the driver
|
||||||
|
// can wait for layout instead of guessing with a fixed sleep.
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
import ImageIO
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum ScreenshotMode {
|
||||||
|
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||||
|
static var requestedScene: ShotScene? {
|
||||||
|
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||||
|
guard !name.isEmpty else { return nil }
|
||||||
|
return ShotScenes.all.first { $0.name == name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-bleed host for a single scene, with per-platform window sizing / orientation and a
|
||||||
|
/// readiness ping for the capture script.
|
||||||
|
struct ScreenshotHostView: View {
|
||||||
|
let scene: ShotScene
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
scene.make()
|
||||||
|
.environment(\.colorScheme, scene.colorScheme)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
#if os(macOS)
|
||||||
|
.background(MacShotWindowConfigurator(scene: scene))
|
||||||
|
#elseif os(iOS)
|
||||||
|
.background(IOSOrientationConfigurator(orientation: scene.orientation))
|
||||||
|
#endif
|
||||||
|
.task {
|
||||||
|
// Let layout + materials settle, then signal the driver.
|
||||||
|
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||||
|
announceReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func announceReady() {
|
||||||
|
print("PF_SHOT_READY scene=\(scene.name)")
|
||||||
|
fflush(stdout)
|
||||||
|
#if os(macOS)
|
||||||
|
MacSelfCapture.captureIfRequested(scene: scene)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Sizes the hosting window to the mac canvas, strips the title bar to a clean full-bleed
|
||||||
|
/// surface, and prints the CGWindowID for `screencapture -l`.
|
||||||
|
private struct MacShotWindowConfigurator: NSViewRepresentable {
|
||||||
|
let scene: ShotScene
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView { NSView() }
|
||||||
|
|
||||||
|
func updateNSView(_ view: NSView, context: Context) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let window = view.window, !context.coordinator.configured else { return }
|
||||||
|
context.coordinator.configured = true
|
||||||
|
// NavigationStack / Form / material chrome follow the WINDOW's appearance, not the
|
||||||
|
// SwiftUI colorScheme — without this the dark scenes render on a light window (white
|
||||||
|
// background, washed-out materials).
|
||||||
|
window.appearance = NSAppearance(named: scene.colorScheme == .dark ? .darkAqua : .aqua)
|
||||||
|
let size = ShotDevice.mac.points(scene.orientation)
|
||||||
|
window.styleMask = [.titled, .fullSizeContentView]
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.titleVisibility = .hidden
|
||||||
|
window.isMovable = false
|
||||||
|
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
|
||||||
|
window.standardWindowButton(button)?.isHidden = true
|
||||||
|
}
|
||||||
|
window.setContentSize(size)
|
||||||
|
window.center()
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
print("PF_SHOT_WINDOW=\(window.windowNumber) scene=\(scene.name) "
|
||||||
|
+ "size=\(Int(size.width))x\(Int(size.height))pt")
|
||||||
|
fflush(stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
final class Coordinator { var configured = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No-permission fallback: capture the window's view tree via cacheDisplay. Renders the real
|
||||||
|
/// hierarchy (NavigationStack/Form/cards — unlike ImageRenderer) but omits material blur, which
|
||||||
|
/// only the window server (screencapture) composites. Used when PUNKTFUNK_SHOT_SELFCAPTURE is set.
|
||||||
|
enum MacSelfCapture {
|
||||||
|
static func captureIfRequested(scene: ShotScene) {
|
||||||
|
guard let dir = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SELFCAPTURE"],
|
||||||
|
!dir.isEmpty,
|
||||||
|
let window = NSApp.windows.first(where: { $0.isVisible }),
|
||||||
|
let content = window.contentView else { return }
|
||||||
|
let outDir = URL(fileURLWithPath: (dir as NSString).expandingTildeInPath, isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
|
||||||
|
guard let rep = content.bitmapImageRepForCachingDisplay(in: content.bounds) else { return }
|
||||||
|
content.cacheDisplay(in: content.bounds, to: rep)
|
||||||
|
let url = outDir.appendingPathComponent("\(ShotDevice.mac.id)-\(scene.name).png")
|
||||||
|
if let dest = CGImageDestinationCreateWithURL(
|
||||||
|
url as CFURL, "public.png" as CFString, 1, nil), let cg = rep.cgImage {
|
||||||
|
CGImageDestinationAddImage(dest, cg, nil)
|
||||||
|
CGImageDestinationFinalize(dest)
|
||||||
|
print("PF_SHOT_SAVED \(url.path) \(rep.pixelsWide)x\(rep.pixelsHigh)px")
|
||||||
|
}
|
||||||
|
fflush(stdout)
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
||||||
|
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
||||||
|
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||||
|
let orientation: ShotOrientation
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
||||||
|
|
||||||
|
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
||||||
|
guard let scene = vc.view.window?.windowScene else { return }
|
||||||
|
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
||||||
|
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||||
|
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// App Store screenshot scenes — the actual screens we render, each wired with mock data so it
|
||||||
|
// looks populated without a live host. Every scene is built from the REAL app views (HomeView,
|
||||||
|
// SettingsView, PairSheet, TrustCardView) so the screenshots track the shipping UI; only the
|
||||||
|
// live stream is faked (StreamView needs a real punktfunk/1 connection — see ShotStreamHero).
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// One screen to capture: a name (→ file suffix), the canvas orientation, a color scheme, and a
|
||||||
|
/// factory that builds the populated view on the main actor.
|
||||||
|
struct ShotScene {
|
||||||
|
let name: String
|
||||||
|
let orientation: ShotOrientation
|
||||||
|
let colorScheme: ColorScheme
|
||||||
|
let make: @MainActor () -> AnyView
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum ShotScenes {
|
||||||
|
static let all: [ShotScene] = [
|
||||||
|
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||||
|
AnyView(ShotStreamHero())
|
||||||
|
},
|
||||||
|
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotHome())
|
||||||
|
},
|
||||||
|
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotPair())
|
||||||
|
},
|
||||||
|
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||||
|
AnyView(ShotTrust())
|
||||||
|
},
|
||||||
|
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||||
|
AnyView(ShotSettings())
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mock data
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum ShotMock {
|
||||||
|
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
|
||||||
|
static func hostStore() -> HostStore {
|
||||||
|
let store = HostStore()
|
||||||
|
store.hosts = [
|
||||||
|
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||||
|
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
||||||
|
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||||
|
pinnedSHA256: fingerprint),
|
||||||
|
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
||||||
|
]
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||||
|
pinnedSHA256: fingerprint)
|
||||||
|
|
||||||
|
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||||
|
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Home
|
||||||
|
|
||||||
|
private struct ShotHome: View {
|
||||||
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
|
@StateObject private var model = SessionModel()
|
||||||
|
@StateObject private var discovery = HostDiscovery()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
HomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||||
|
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in },
|
||||||
|
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||||
|
#else
|
||||||
|
HomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||||
|
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||||
|
showSettings: .constant(false),
|
||||||
|
connect: { _ in }, connectDiscovered: { _ in },
|
||||||
|
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
private struct ShotSettings: View {
|
||||||
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
// The mac Settings window is a fixed-size tabbed panel — float it over a dimmed host
|
||||||
|
// grid so the shot reads as the preferences window over the running app.
|
||||||
|
ZStack {
|
||||||
|
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||||
|
SettingsView()
|
||||||
|
.fixedSize()
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(radius: 40, y: 16)
|
||||||
|
}
|
||||||
|
#elseif os(iOS)
|
||||||
|
NavigationStack {
|
||||||
|
SettingsView()
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
NavigationStack { SettingsView() }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pair (PIN ceremony)
|
||||||
|
|
||||||
|
private struct ShotPair: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||||
|
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
||||||
|
.frame(maxWidth: 460)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||||
|
.shadow(radius: 40, y: 16)
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Trust (TOFU card over the blurred live stream)
|
||||||
|
|
||||||
|
private struct ShotTrust: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ShotDesktopFrame()
|
||||||
|
.blur(radius: 32)
|
||||||
|
.overlay(Color.black.opacity(0.45))
|
||||||
|
TrustCardView(
|
||||||
|
fingerprint: ShotMock.fingerprint, hostName: "Battlestation",
|
||||||
|
onCancel: {}, onTrust: {}, onPairInstead: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stream hero
|
||||||
|
|
||||||
|
/// The marketing hero: a stand-in streamed frame with the real glass HUD chip on top.
|
||||||
|
/// StreamView can't render here (it needs a live punktfunk/1 connection), so the frame is
|
||||||
|
/// synthetic — set `PUNKTFUNK_SHOT_HERO=/path/to/frame.png` to drop in a real captured frame.
|
||||||
|
private struct ShotStreamHero: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
ShotDesktopFrame()
|
||||||
|
ShotHUD()
|
||||||
|
}
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A faithful copy of StreamHUDView's overlay (which needs a live PunktfunkConnection for the
|
||||||
|
/// mode line) with representative numbers, reusing the app's real `.glassBackground`.
|
||||||
|
private struct ShotHUD: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle().fill(Color.accentColor).frame(width: 7, height: 7)
|
||||||
|
Text("5120×1440@240 240 fps 812.4 Mb/s")
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
}
|
||||||
|
Text("capture→client 1.3/2.1 ms p50/p95")
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
#if os(macOS)
|
||||||
|
Text("⌘⎋ releases the mouse")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
#elseif os(tvOS)
|
||||||
|
Text("Press Menu to disconnect")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A synthetic "streamed frame" — a synthwave scene that reads as game content without shipping
|
||||||
|
/// any real art. Replaced wholesale when `PUNKTFUNK_SHOT_HERO` points at a real PNG.
|
||||||
|
private struct ShotDesktopFrame: View {
|
||||||
|
var body: some View {
|
||||||
|
if let image = Self.overrideImage {
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
} else {
|
||||||
|
synthetic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var synthetic: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.05, green: 0.02, blue: 0.16),
|
||||||
|
Color(red: 0.35, green: 0.05, blue: 0.42),
|
||||||
|
Color(red: 0.95, green: 0.30, blue: 0.35),
|
||||||
|
Color(red: 0.99, green: 0.62, blue: 0.32),
|
||||||
|
],
|
||||||
|
startPoint: .top, endPoint: .bottom)
|
||||||
|
Canvas { ctx, size in
|
||||||
|
let horizon = size.height * 0.52
|
||||||
|
// Sun.
|
||||||
|
let sunR = size.height * 0.20
|
||||||
|
let sun = CGRect(x: size.width / 2 - sunR, y: horizon - sunR * 1.6,
|
||||||
|
width: sunR * 2, height: sunR * 2)
|
||||||
|
ctx.fill(Path(ellipseIn: sun),
|
||||||
|
with: .linearGradient(
|
||||||
|
Gradient(colors: [Color(red: 1, green: 0.95, blue: 0.5),
|
||||||
|
Color(red: 1, green: 0.35, blue: 0.45)]),
|
||||||
|
startPoint: CGPoint(x: sun.midX, y: sun.minY),
|
||||||
|
endPoint: CGPoint(x: sun.midX, y: sun.maxY)))
|
||||||
|
// Sun scanlines — clip a copy so the base context stays unclipped (GraphicsContext
|
||||||
|
// is a value type; there is no resetClip).
|
||||||
|
var sunCtx = ctx
|
||||||
|
sunCtx.clip(to: Path(ellipseIn: sun))
|
||||||
|
for i in 0..<7 {
|
||||||
|
let y = sun.minY + sun.height * (0.55 + Double(i) * 0.07)
|
||||||
|
let bar = CGRect(x: sun.minX, y: y, width: sun.width,
|
||||||
|
height: sun.height * (0.012 + Double(i) * 0.006))
|
||||||
|
sunCtx.fill(Path(bar), with: .color(.black.opacity(0.85)))
|
||||||
|
}
|
||||||
|
// Perspective grid below the horizon.
|
||||||
|
ctx.opacity = 0.55
|
||||||
|
let cx = size.width / 2
|
||||||
|
for col in -10...10 {
|
||||||
|
var p = Path()
|
||||||
|
p.move(to: CGPoint(x: cx, y: horizon))
|
||||||
|
p.addLine(to: CGPoint(x: cx + Double(col) * size.width * 0.11,
|
||||||
|
y: size.height))
|
||||||
|
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||||
|
lineWidth: 1.5)
|
||||||
|
}
|
||||||
|
var row = horizon
|
||||||
|
var step = size.height * 0.012
|
||||||
|
while row < size.height {
|
||||||
|
var p = Path()
|
||||||
|
p.move(to: CGPoint(x: 0, y: row))
|
||||||
|
p.addLine(to: CGPoint(x: size.width, y: row))
|
||||||
|
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||||
|
lineWidth: 1.5)
|
||||||
|
step *= 1.32
|
||||||
|
row += step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
// A small "now playing" chip so the frame reads as live content, not a wallpaper.
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "gamecontroller.fill")
|
||||||
|
Text("Streaming from Battlestation")
|
||||||
|
.font(.system(.callout, weight: .semibold))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||||
|
.glassBackground(Capsule())
|
||||||
|
.padding(18)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUNKTFUNK_SHOT_HERO=/abs/path.png` → use a real captured frame as the hero background.
|
||||||
|
static var overrideImage: Image? {
|
||||||
|
guard let path = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_HERO"],
|
||||||
|
!path.isEmpty, FileManager.default.fileExists(atPath: path) else { return nil }
|
||||||
|
#if os(macOS)
|
||||||
|
guard let ns = NSImage(contentsOfFile: path) else { return nil }
|
||||||
|
return Image(nsImage: ns)
|
||||||
|
#else
|
||||||
|
guard let ui = UIImage(contentsOfFile: path) else { return nil }
|
||||||
|
return Image(uiImage: ui)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -28,6 +28,9 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
#if DEBUG && !os(tvOS)
|
||||||
|
@State private var showControllerTest = false
|
||||||
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||||
@@ -411,6 +414,11 @@ struct SettingsView: View {
|
|||||||
Text(option.label).tag(option.tag)
|
Text(option.label).tag(option.tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if DEBUG && !os(tvOS)
|
||||||
|
Button("Test Controller…") { showControllerTest = true }
|
||||||
|
.disabled(gamepads.active == nil)
|
||||||
|
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
|
||||||
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controllers")
|
Text("Controllers")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Raw-HID DualSense rumble for macOS.
|
||||||
|
//
|
||||||
|
// Apple's GameController/CHHapticEngine path does NOT drive the DualSense's rumble motors on
|
||||||
|
// macOS — a documented platform gap: adaptive triggers, lightbar and player LEDs all work
|
||||||
|
// (different APIs), but `CHHapticEngine` output never reaches the motors. So we write the motor
|
||||||
|
// amplitudes straight into the DualSense HID output report, exactly the way SDL and the Linux
|
||||||
|
// `hid-playstation` driver do (the same report that already rumbles this pad on a Linux host).
|
||||||
|
//
|
||||||
|
// USB (report 0x02, 48 bytes, no CRC) and Bluetooth (report 0x31, 78 bytes, trailing CRC32) are
|
||||||
|
// both handled. The App Sandbox permits the raw-HID access via the app's `device.usb` +
|
||||||
|
// `device.bluetooth` entitlements, and this coexists with GameController holding the same device
|
||||||
|
// (non-seized open). Output-only, so no run-loop scheduling is needed.
|
||||||
|
//
|
||||||
|
// macOS-only: IOKit HID device access isn't available to apps on iOS/tvOS.
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
import IOKit.hid
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||||
|
|
||||||
|
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||||
|
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||||
|
final class DualSenseHID {
|
||||||
|
private let manager: IOHIDManager
|
||||||
|
private var device: IOHIDDevice?
|
||||||
|
private var bluetooth = false
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
private static let vendorSony = 0x054C
|
||||||
|
// DualSense (0x0CE6) and DualSense Edge (0x0DF2). The DualShock 4 uses a different report
|
||||||
|
// layout and is intentionally not handled here.
|
||||||
|
private static let productIDs = [0x0CE6, 0x0DF2]
|
||||||
|
|
||||||
|
/// "USB" or "Bluetooth" — for logs / the debug panel. Valid after a successful `open()`.
|
||||||
|
var transport: String { bluetooth ? "Bluetooth" : "USB" }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { close() }
|
||||||
|
|
||||||
|
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||||
|
/// be opened (caller then falls back to CoreHaptics).
|
||||||
|
func open() -> Bool {
|
||||||
|
let matches = Self.productIDs.map { pid in
|
||||||
|
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||||
|
}
|
||||||
|
IOHIDManagerSetDeviceMatchingMultiple(manager, matches as CFArray)
|
||||||
|
guard IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||||
|
log.info("rumble: DualSense HID manager open failed — falling back to CoreHaptics")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||||
|
let dev = devices.first
|
||||||
|
else {
|
||||||
|
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||||
|
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
device = dev
|
||||||
|
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||||
|
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||||
|
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||||
|
/// each 0...255. (0, 0) stops.
|
||||||
|
func rumble(low: UInt8, high: UInt8) {
|
||||||
|
guard let dev = device else { return }
|
||||||
|
let report = bluetooth
|
||||||
|
? Self.bluetoothReport(low: low, high: high)
|
||||||
|
: Self.usbReport(low: low, high: high)
|
||||||
|
let rc = report.withUnsafeBufferPointer { buf in
|
||||||
|
IOHIDDeviceSetReport(
|
||||||
|
dev, kIOHIDReportTypeOutput, CFIndex(report[0]), buf.baseAddress!, buf.count)
|
||||||
|
}
|
||||||
|
if rc != kIOReturnSuccess {
|
||||||
|
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
guard !closed else { return }
|
||||||
|
closed = true
|
||||||
|
if device != nil { rumble(low: 0, high: 0) } // silence the motors before releasing
|
||||||
|
device = nil
|
||||||
|
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report builders
|
||||||
|
|
||||||
|
// DualSense effects payload (DS5EffectsState_t / hid-playstation `common`) — offsets relative
|
||||||
|
// to the payload start:
|
||||||
|
// 0 flag0 (enable bits) 2 motor_right (high-freq) 3 motor_left (low-freq)
|
||||||
|
// 1 flag1 38 flag2 (enhanced enable)
|
||||||
|
// We mirror the Linux driver: flag0 = COMPATIBLE_VIBRATION | HAPTICS_SELECT, flag2 =
|
||||||
|
// COMPATIBLE_VIBRATION2 (the enhanced-firmware path), motors sent directly. valid_flag1 stays
|
||||||
|
// 0 so this rumble-only report leaves the lightbar / triggers / player LEDs (driven by
|
||||||
|
// GameController) untouched.
|
||||||
|
private static func fillEffects(_ data: inout [UInt8], at base: Int, low: UInt8, high: UInt8) {
|
||||||
|
data[base + 0] = 0x03 // COMPATIBLE_VIBRATION (0x01) | HAPTICS_SELECT (0x02)
|
||||||
|
data[base + 2] = high // motor_right
|
||||||
|
data[base + 3] = low // motor_left
|
||||||
|
data[base + 38] = 0x04 // COMPATIBLE_VIBRATION2 (enhanced rumble, firmware ≥ 0x0224)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `usbReport` / `bluetoothReport` / `crc32` are internal (not private) so the unit tests can
|
||||||
|
// pin the exact wire layout against the SDL / hid-playstation spec without a physical pad.
|
||||||
|
static func usbReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||||
|
var d = [UInt8](repeating: 0, count: 48)
|
||||||
|
d[0] = 0x02 // report id
|
||||||
|
fillEffects(&d, at: 1, low: low, high: high)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bluetoothReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||||
|
var d = [UInt8](repeating: 0, count: 78)
|
||||||
|
d[0] = 0x31 // report id
|
||||||
|
d[1] = 0x00 // seq/tag (static, as SDL)
|
||||||
|
d[2] = 0x10 // magic
|
||||||
|
fillEffects(&d, at: 3, low: low, high: high)
|
||||||
|
// Trailing CRC32 over a 0xA2 seed byte + the report minus its 4 CRC bytes, little-endian.
|
||||||
|
let crc = Self.crc32(seed: 0xA2, d[0..<(d.count - 4)])
|
||||||
|
d[74] = UInt8(crc & 0xFF)
|
||||||
|
d[75] = UInt8((crc >> 8) & 0xFF)
|
||||||
|
d[76] = UInt8((crc >> 16) & 0xFF)
|
||||||
|
d[77] = UInt8((crc >> 24) & 0xFF)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard reflected CRC32 (zlib poly 0xEDB88320, init 0xFFFFFFFF, final XOR) over `seed`
|
||||||
|
/// followed by `bytes` — the DualSense Bluetooth output-report checksum (seed 0xA2). Matches
|
||||||
|
/// SDL's `SDL_crc32`/the kernel's `crc32_le` framing.
|
||||||
|
static func crc32<S: Sequence>(seed: UInt8, _ bytes: S) -> UInt32
|
||||||
|
where S.Element == UInt8 {
|
||||||
|
var crc: UInt32 = 0xFFFF_FFFF
|
||||||
|
func step(_ b: UInt8) {
|
||||||
|
crc ^= UInt32(b)
|
||||||
|
for _ in 0..<8 {
|
||||||
|
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB8_8320 : crc >> 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step(seed)
|
||||||
|
for b in bytes { step(b) }
|
||||||
|
return ~crc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -50,10 +50,12 @@ private final class FeedbackStopFlag: @unchecked Sendable {
|
|||||||
private final class RumbleRenderer: @unchecked Sendable {
|
private final class RumbleRenderer: @unchecked Sendable {
|
||||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||||
|
|
||||||
|
/// One actuator's started engine plus the player currently driving it (nil = idle). The
|
||||||
|
/// player is rebuilt per level change — `drive` bakes the target intensity into a fresh
|
||||||
|
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
|
||||||
private struct Motor {
|
private struct Motor {
|
||||||
let engine: CHHapticEngine
|
let engine: CHHapticEngine
|
||||||
let player: CHHapticAdvancedPatternPlayer
|
var player: CHHapticAdvancedPatternPlayer?
|
||||||
var playing = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var controller: GCController?
|
private var controller: GCController?
|
||||||
@@ -67,11 +69,30 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||||
private var wasActive = false
|
private var wasActive = false
|
||||||
|
|
||||||
func retarget(_ c: GCController?) {
|
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||||
|
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||||
|
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
|
||||||
|
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
|
||||||
|
/// CoreHaptics path — a DualSense on macOS is driven over raw HID instead, see below.)
|
||||||
|
private static let sharpness: Float = 0.5
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
|
||||||
|
/// does not reach them on macOS — adaptive triggers/lightbar work, rumble is silent). nil for
|
||||||
|
/// every other controller, which keeps the CoreHaptics path.
|
||||||
|
private var dualSenseHID: DualSenseHID?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||||
|
/// rumble backend now in use — for the debug controller-test panel.
|
||||||
|
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
|
||||||
queue.async {
|
queue.async {
|
||||||
self.teardown()
|
self.teardown()
|
||||||
|
self.closeHID()
|
||||||
self.controller = c
|
self.controller = c
|
||||||
self.broken = false
|
self.broken = false
|
||||||
|
_ = self.openHIDIfDualSense(c)
|
||||||
|
onBackend?(self.backendNote(for: c))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,22 +104,36 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
log.debug(
|
log.debug(
|
||||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
|
||||||
|
// other pad (and for a DualSense whose HID device could not be opened).
|
||||||
|
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||||
guard !self.broken else { return }
|
guard !self.broken else { return }
|
||||||
if active, self.low == nil, self.high == nil {
|
if active, self.low == nil, self.high == nil {
|
||||||
self.setup()
|
self.setup()
|
||||||
}
|
}
|
||||||
|
let ok: Bool
|
||||||
if self.high != nil {
|
if self.high != nil {
|
||||||
self.drive(&self.low, Float(lowAmp) / 65535)
|
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||||
self.drive(&self.high, Float(highAmp) / 65535)
|
// the wire carries.
|
||||||
|
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
|
||||||
|
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
|
||||||
|
ok = okLow && okHigh
|
||||||
} else {
|
} else {
|
||||||
// Combined engine: whichever motor is stronger wins.
|
// Combined engine: whichever motor is stronger wins.
|
||||||
self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||||
}
|
}
|
||||||
|
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
||||||
|
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||||
|
// still holds an exclusive reference to.
|
||||||
|
if !ok { self.teardown() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
queue.sync { self.teardown() }
|
queue.sync {
|
||||||
|
self.teardown()
|
||||||
|
self.closeHID()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
||||||
@@ -144,44 +179,51 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
self?.queue.async { self?.teardown() }
|
self?.queue.async { self?.teardown() }
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
// Start the engine now; the player that actually moves the motor is built per level
|
||||||
|
// change in `drive` (a fresh event baked at the target intensity).
|
||||||
try engine.start()
|
try engine.start()
|
||||||
let event = CHHapticEvent(
|
return Motor(engine: engine, player: nil)
|
||||||
eventType: .hapticContinuous,
|
|
||||||
parameters: [CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)],
|
|
||||||
relativeTime: 0,
|
|
||||||
duration: TimeInterval(GCHapticDurationInfinite))
|
|
||||||
let player = try engine.makeAdvancedPlayer(with: CHHapticPattern(events: [event], parameters: []))
|
|
||||||
return Motor(engine: engine, player: player)
|
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) {
|
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
|
||||||
guard var m = motor else { return }
|
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
|
||||||
|
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
|
||||||
|
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
|
||||||
|
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
|
||||||
|
/// duration so a single host update — the host sends rumble only when the level changes —
|
||||||
|
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
|
||||||
|
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
|
||||||
|
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
|
||||||
|
guard var m = motor else { return true }
|
||||||
|
// Replace any running player: stop the old, and for a zero level leave the motor idle.
|
||||||
|
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||||
|
m.player = nil
|
||||||
|
guard amplitude > 0 else { motor = m; return true }
|
||||||
do {
|
do {
|
||||||
if amplitude > 0 {
|
let event = CHHapticEvent(
|
||||||
if !m.playing {
|
eventType: .hapticContinuous,
|
||||||
try m.player.start(atTime: CHHapticTimeImmediate)
|
parameters: [
|
||||||
m.playing = true
|
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||||
}
|
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
|
||||||
try m.player.sendParameters(
|
],
|
||||||
[CHHapticDynamicParameter(
|
relativeTime: 0,
|
||||||
parameterID: .hapticIntensityControl,
|
duration: TimeInterval(GCHapticDurationInfinite))
|
||||||
value: amplitude, relativeTime: 0)],
|
let player = try m.engine.makeAdvancedPlayer(
|
||||||
atTime: CHHapticTimeImmediate)
|
with: CHHapticPattern(events: [event], parameters: []))
|
||||||
} else if m.playing {
|
try player.start(atTime: CHHapticTimeImmediate)
|
||||||
try m.player.stop(atTime: CHHapticTimeImmediate)
|
m.player = player
|
||||||
m.playing = false
|
|
||||||
}
|
|
||||||
motor = m
|
motor = m
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||||
// Tear down so the next nonzero amplitude rebuilds — do NOT latch rumble off for the
|
// Signal a rebuild — do NOT latch rumble off for the session (the old "spotty" bug).
|
||||||
// session (that was the old "spotty" behaviour).
|
|
||||||
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
||||||
teardown()
|
motor = m
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,12 +233,56 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||||
m.engine.stoppedHandler = { _ in }
|
m.engine.stoppedHandler = { _ in }
|
||||||
m.engine.resetHandler = {}
|
m.engine.resetHandler = {}
|
||||||
try? m.player.stop(atTime: CHHapticTimeImmediate)
|
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||||
m.engine.stop()
|
m.engine.stop()
|
||||||
}
|
}
|
||||||
low = nil
|
low = nil
|
||||||
high = nil
|
high = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - DualSense raw-HID rumble (macOS)
|
||||||
|
//
|
||||||
|
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
|
||||||
|
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
|
||||||
|
// All three run on the serial `queue`, like the rest of the renderer state.
|
||||||
|
|
||||||
|
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
|
||||||
|
#if os(macOS)
|
||||||
|
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||||
|
let hid = DualSenseHID()
|
||||||
|
guard hid.open() else { return false }
|
||||||
|
dualSenseHID = hid
|
||||||
|
return true
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the DualSense's motors over HID if that's the active backend; false → not a HID pad,
|
||||||
|
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
|
||||||
|
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
|
||||||
|
#if os(macOS)
|
||||||
|
guard let hid = dualSenseHID else { return false }
|
||||||
|
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
|
||||||
|
return true
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeHID() {
|
||||||
|
#if os(macOS)
|
||||||
|
dualSenseHID?.close()
|
||||||
|
dualSenseHID = nil
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func backendNote(for c: GCController?) -> String {
|
||||||
|
#if os(macOS)
|
||||||
|
if let hid = dualSenseHID { return "DualSense HID · \(hid.transport)" }
|
||||||
|
#endif
|
||||||
|
return c == nil ? "—" : "CoreHaptics"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class GamepadFeedback {
|
public final class GamepadFeedback {
|
||||||
@@ -369,3 +455,74 @@ public final class GamepadFeedback {
|
|||||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds
|
||||||
|
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
|
||||||
|
/// live session uses — just aimed at the physically-connected controller instead of the
|
||||||
|
/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player
|
||||||
|
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
|
||||||
|
/// a passing test exercises the exact code a session runs.
|
||||||
|
@MainActor
|
||||||
|
public final class ControllerTester: ObservableObject {
|
||||||
|
private let renderer = RumbleRenderer()
|
||||||
|
private weak var controller: GCController?
|
||||||
|
|
||||||
|
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||||
|
/// for the test panel to display so it's obvious which path a given pad takes.
|
||||||
|
@Published public private(set) var rumbleBackend = "—"
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
|
||||||
|
/// active-controller change.
|
||||||
|
public func target(_ c: GCController?) {
|
||||||
|
guard c !== controller else { return }
|
||||||
|
controller = c
|
||||||
|
renderer.retarget(c) { [weak self] note in
|
||||||
|
Task { @MainActor in self?.rumbleBackend = note }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
|
||||||
|
/// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
|
||||||
|
public func rumble(low: Float, high: Float) {
|
||||||
|
func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
|
||||||
|
renderer.apply(low: u16(low), high: u16(high))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopRumble() { renderer.apply(low: 0, high: 0) }
|
||||||
|
|
||||||
|
/// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
|
||||||
|
/// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad.
|
||||||
|
public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
|
||||||
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||||
|
effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resetTriggers() {
|
||||||
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||||
|
ds.leftTrigger.setModeOff()
|
||||||
|
ds.rightTrigger.setModeOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
|
||||||
|
public func setLight(_ color: GCColor?) {
|
||||||
|
controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
|
||||||
|
public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
|
||||||
|
controller?.playerIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silence every channel and release the controller — call on the panel's disappear.
|
||||||
|
public func stop() {
|
||||||
|
resetTriggers()
|
||||||
|
setPlayerIndex(.indexUnset)
|
||||||
|
setLight(nil)
|
||||||
|
renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
|
||||||
|
controller = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Locks the DualSense raw-HID rumble report layout to the SDL / Linux hid-playstation spec.
|
||||||
|
// The motors can only be confirmed on a physical pad, but these guard against a silent byte
|
||||||
|
// error in the offsets, enable flags, lengths, and the Bluetooth CRC32 — the parts most likely
|
||||||
|
// to regress unnoticed. macOS-only (DualSenseHID isn't compiled elsewhere).
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class DualSenseHIDTests: XCTestCase {
|
||||||
|
func testUSBReportLayout() {
|
||||||
|
let r = DualSenseHID.usbReport(low: 0xAA, high: 0xBB)
|
||||||
|
XCTAssertEqual(r.count, 48)
|
||||||
|
XCTAssertEqual(r[0], 0x02) // report id
|
||||||
|
XCTAssertEqual(r[1], 0x03) // flag0: COMPATIBLE_VIBRATION | HAPTICS_SELECT
|
||||||
|
XCTAssertEqual(r[2], 0x00) // flag1 (untouched — leaves lightbar/LEDs alone)
|
||||||
|
XCTAssertEqual(r[3], 0xBB) // motor_right = high
|
||||||
|
XCTAssertEqual(r[4], 0xAA) // motor_left = low
|
||||||
|
XCTAssertEqual(r[39], 0x04) // flag2: COMPATIBLE_VIBRATION2 (payload offset 38 + report id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBluetoothReportLayoutAndCRC() {
|
||||||
|
let r = DualSenseHID.bluetoothReport(low: 0xAA, high: 0xBB)
|
||||||
|
XCTAssertEqual(r.count, 78)
|
||||||
|
XCTAssertEqual(r[0], 0x31) // report id
|
||||||
|
XCTAssertEqual(r[1], 0x00) // seq/tag
|
||||||
|
XCTAssertEqual(r[2], 0x10) // magic
|
||||||
|
XCTAssertEqual(r[3], 0x03) // flag0
|
||||||
|
XCTAssertEqual(r[5], 0xBB) // motor_right = high (payload offset 2 + 3-byte BT header)
|
||||||
|
XCTAssertEqual(r[6], 0xAA) // motor_left = low
|
||||||
|
XCTAssertEqual(r[41], 0x04) // flag2 (payload offset 38 + 3)
|
||||||
|
|
||||||
|
// Trailing CRC32 = standard CRC32 over (0xA2 seed + report[0..<74]), little-endian.
|
||||||
|
let expected = DualSenseHID.crc32(seed: 0xA2, r[0..<74])
|
||||||
|
let stored = UInt32(r[74]) | (UInt32(r[75]) << 8) | (UInt32(r[76]) << 16) | (UInt32(r[77]) << 24)
|
||||||
|
XCTAssertEqual(stored, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCRC32MatchesStandardCheckVector() {
|
||||||
|
// The canonical CRC32 check value: CRC32("123456789") == 0xCBF43926. Our helper folds a
|
||||||
|
// seed byte in first, so feed seed='1' and the rest — proving poly/reflection/init/final.
|
||||||
|
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||||
|
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Executable
+168
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# App Store screenshot driver for the Punktfunk Apple client.
|
||||||
|
#
|
||||||
|
# Launches the app in "shot mode" (PUNKTFUNK_SHOT_SCENE=<name> → one mock-populated screen,
|
||||||
|
# full-bleed; see Sources/PunktfunkClient/Screenshots/) once per scene per device, and lets the OS
|
||||||
|
# capture the REAL rendered UI:
|
||||||
|
# • macOS → `screencapture` of the app's borderless window.
|
||||||
|
# • iOS/iPadOS/tvOS → a booted Simulator + `xcrun simctl io booted screenshot` (native pixels =
|
||||||
|
# the exact App Store size for that device).
|
||||||
|
#
|
||||||
|
# The captured pixels are exactly App Store Connect's required sizes:
|
||||||
|
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
|
||||||
|
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
|
||||||
|
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
|
||||||
|
# appletv 1920×1080
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
|
||||||
|
# for your terminal (System Settings → Privacy & Security → Screen Recording).
|
||||||
|
# • iOS/iPadOS/tvOS targets: full Xcode (xcodebuild + Simulators), not just Command Line Tools.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# tools/screenshots.sh all # every platform this machine can build
|
||||||
|
# tools/screenshots.sh macos # just macOS
|
||||||
|
# tools/screenshots.sh ios ipad tvos # specific platforms
|
||||||
|
# OUT=~/Desktop/shots tools/screenshots.sh all
|
||||||
|
# PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame for the hero
|
||||||
|
#
|
||||||
|
# Keep SCENES in sync with ShotScenes.all.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APPLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$APPLE_DIR"
|
||||||
|
|
||||||
|
OUT="${OUT:-$APPLE_DIR/screenshots}"
|
||||||
|
BUNDLE_ID="io.unom.punktfunk"
|
||||||
|
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
|
||||||
|
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||||
|
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
log() { printf '\033[1;36m[shots]\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m[shots]\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[1;31m[shots]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
require_xcode() {
|
||||||
|
xcrun --find simctl >/dev/null 2>&1 \
|
||||||
|
|| die "Full Xcode required for simulator capture (have Command Line Tools only).
|
||||||
|
Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- macOS
|
||||||
|
|
||||||
|
shoot_macos() {
|
||||||
|
log "macOS — building (swift build -c release)…"
|
||||||
|
swift build -c release >/dev/null
|
||||||
|
local bin=".build/release/PunktfunkClient"
|
||||||
|
[ -x "$bin" ] || die "build produced no $bin"
|
||||||
|
|
||||||
|
for scene in "${SCENES[@]}"; do
|
||||||
|
local logf; logf="$(mktemp)"
|
||||||
|
PUNKTFUNK_SHOT_SCENE="$scene" "$bin" >"$logf" 2>&1 &
|
||||||
|
local pid=$!
|
||||||
|
# Wait for the window to exist and the scene to settle.
|
||||||
|
local win=""
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
win="$(grep -o 'PF_SHOT_WINDOW=[0-9]*' "$logf" | head -1 | cut -d= -f2 || true)"
|
||||||
|
[ -n "$win" ] && grep -q PF_SHOT_READY "$logf" && break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
if [ -z "$win" ]; then
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
warn "macOS/$scene: app never reported a window — skipping"; cat "$logf" >&2; continue
|
||||||
|
fi
|
||||||
|
local dest="$OUT/mac-$scene.png"
|
||||||
|
if screencapture -x -o -l"$win" "$dest" 2>/dev/null && [ -s "$dest" ]; then
|
||||||
|
log "macOS/$scene → $dest ($(pixels "$dest"))"
|
||||||
|
else
|
||||||
|
warn "macOS/$scene: screencapture failed — grant your terminal Screen Recording permission
|
||||||
|
(System Settings → Privacy & Security → Screen Recording), then re-run."
|
||||||
|
fi
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
rm -f "$logf"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ iOS / iPadOS / tvOS
|
||||||
|
|
||||||
|
# $1 device-type regex (matches both existing device names and the device-type catalog)
|
||||||
|
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
|
||||||
|
shoot_sim() {
|
||||||
|
require_xcode
|
||||||
|
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
|
||||||
|
|
||||||
|
# Reuse an existing device of this type; else create a throwaway one against the newest
|
||||||
|
# available runtime for the platform. CI runners commonly ship a runtime but not every device
|
||||||
|
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
|
||||||
|
local udid
|
||||||
|
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
|
||||||
|
if [ -z "$udid" ]; then
|
||||||
|
local devtype rt
|
||||||
|
devtype="$(xcrun simctl list devicetypes | grep -E "$match" \
|
||||||
|
| grep -oE 'com\.apple\.CoreSimulator\.SimDeviceType\.[A-Za-z0-9.-]+' | head -1 || true)"
|
||||||
|
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
|
||||||
|
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
|
||||||
|
if [ -n "$devtype" ] && [ -n "$rt" ]; then
|
||||||
|
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
|
||||||
|
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
|
||||||
|
(needs a $platform runtime + a matching device type — check 'xcrun simctl list')."
|
||||||
|
log "$prefix — Simulator $udid"
|
||||||
|
xcrun simctl boot "$udid" 2>/dev/null || true
|
||||||
|
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
log "$prefix — building ($scheme)…"
|
||||||
|
local dd; dd="$(mktemp -d)"
|
||||||
|
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
|
||||||
|
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|
||||||
|
|| die "$prefix: xcodebuild failed"
|
||||||
|
local app; app="$(find "$dd/Build/Products" -maxdepth 2 -name '*.app' -type d | head -1)"
|
||||||
|
[ -n "$app" ] || die "$prefix: no .app built"
|
||||||
|
xcrun simctl install "$udid" "$app"
|
||||||
|
|
||||||
|
for scene in "${SCENES[@]}"; do
|
||||||
|
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
|
||||||
|
SIMCTL_CHILD_PUNKTFUNK_SHOT_SCENE="$scene" \
|
||||||
|
${PUNKTFUNK_SHOT_HERO:+SIMCTL_CHILD_PUNKTFUNK_SHOT_HERO="$PUNKTFUNK_SHOT_HERO"} \
|
||||||
|
xcrun simctl launch "$udid" "$BUNDLE_ID" >/dev/null
|
||||||
|
sleep "$SETTLE"
|
||||||
|
local dest="$OUT/$prefix-$scene.png"
|
||||||
|
xcrun simctl io "$udid" screenshot "$dest" >/dev/null
|
||||||
|
log "$prefix/$scene → $dest ($(pixels "$dest"))"
|
||||||
|
done
|
||||||
|
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
|
||||||
|
rm -rf "$dd"
|
||||||
|
}
|
||||||
|
|
||||||
|
pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{print $2}' | paste -sd× -; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- dispatch
|
||||||
|
|
||||||
|
[ $# -gt 0 ] || set -- all
|
||||||
|
for target in "$@"; do
|
||||||
|
case "$target" in
|
||||||
|
macos) shoot_macos ;;
|
||||||
|
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
|
||||||
|
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
|
||||||
|
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
|
||||||
|
all)
|
||||||
|
shoot_macos
|
||||||
|
if xcrun --find simctl >/dev/null 2>&1; then
|
||||||
|
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
|
||||||
|
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
|
||||||
|
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
|
||||||
|
else
|
||||||
|
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*) die "unknown target '$target' (use: all macos ios ipad tvos)" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Done. Screenshots in $OUT"
|
||||||
|
ls -1 "$OUT" 2>/dev/null || true
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display.
|
||||||
|
#
|
||||||
|
# Deliberately self-contained (no `*.workspace = true` inheritance, no Windows deps): this crate is a
|
||||||
|
# path dependency of BOTH the host workspace (crates/punktfunk-host) AND the out-of-workspace driver
|
||||||
|
# workspace (packaging/windows/drivers/), so it must resolve identically from either build graph. It is
|
||||||
|
# `no_std` (+ alloc) and platform-neutral; the GUID/LUID are plain integers each side converts to its
|
||||||
|
# own OS type. Defining every wire struct ONCE here — with `const` size/offset asserts + bytemuck
|
||||||
|
# round-trips — makes host<->driver ABI drift a COMPILE error instead of a silent frame/IOCTL corruption.
|
||||||
|
[package]
|
||||||
|
name = "pf-driver-proto"
|
||||||
|
version = "0.0.1"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.82"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display (control IOCTLs + IDD-push frame transport)."
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# `min_const_generics`: Pod/Zeroable for `[u8; N]` of any N (the gamepad SHM reserved tails are >32).
|
||||||
|
bytemuck = { version = "1.19", features = ["derive", "min_const_generics"] }
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
//! Shared binary contract between the punktfunk host and the `pf-vdisplay` IddCx driver.
|
||||||
|
//!
|
||||||
|
//! Two planes:
|
||||||
|
//! * [`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.
|
||||||
|
//! * [`frame`] — the IDD-push frame transport: the host creates a ring of shared keyed-mutex textures
|
||||||
|
//! (+ a header + a frame-ready event) and the driver opens them and publishes composited frames into
|
||||||
|
//! them. This crate owns the [`frame::SharedHeader`] layout, the [`frame::FrameToken`] packing, the
|
||||||
|
//! `Global\` object-name scheme, and the driver-status codes.
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//! once here — with bytemuck `Pod` derives and `const` size asserts — makes any drift a compile error.
|
||||||
|
//!
|
||||||
|
//! The GUID and LUID are carried as plain integers; the host converts to `windows::core::GUID` /
|
||||||
|
//! `windows::Win32::Foundation::LUID` and the driver to its own bindgen types via the same constants.
|
||||||
|
|
||||||
|
#![cfg_attr(not(test), no_std)]
|
||||||
|
|
||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
/// Freshly-minted pf-vdisplay device-interface GUID — `{70667664-7044-5350-a1b2-c3d4e5f60001}`.
|
||||||
|
/// Deliberately NOT SudoVDA's `{e5bcc234-…}`: we own the driver, so a private interface GUID signals
|
||||||
|
/// it and removes any accidental coexistence with a real SudoVDA install. Construct on each side via
|
||||||
|
/// `GUID::from_u128(PF_VDISPLAY_INTERFACE_GUID_U128)`.
|
||||||
|
pub const PF_VDISPLAY_INTERFACE_GUID_U128: u128 = 0x7066_7664_7044_5350_a1b2_c3d4_e5f6_0001;
|
||||||
|
|
||||||
|
/// The interface GUID split into Windows `GUID` fields — `(Data1, Data2, Data3, Data4)` — so the driver
|
||||||
|
/// (and host) can build a `windows`/`wdk_sys` `GUID` without re-deriving the byte layout. Standard GUID
|
||||||
|
/// layout from the u128: `Data1` = high 32 bits, `Data2`/`Data3` = next two 16-bit groups, `Data4` =
|
||||||
|
/// the low 64 bits big-endian. (This crate is `no_std` + provider-agnostic, so it returns the fields
|
||||||
|
/// rather than depend on a `GUID` type.)
|
||||||
|
#[must_use]
|
||||||
|
pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
|
||||||
|
let g = PF_VDISPLAY_INTERFACE_GUID_U128;
|
||||||
|
(
|
||||||
|
(g >> 96) as u32,
|
||||||
|
(g >> 80) as u16,
|
||||||
|
(g >> 64) as u16,
|
||||||
|
(g as u64).to_be_bytes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
|
||||||
|
pub const fn ctl_code(func: u32) -> u32 {
|
||||||
|
(0x22u32 << 16) | (func << 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive.
|
||||||
|
pub mod control {
|
||||||
|
use super::ctl_code;
|
||||||
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
|
// Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering.
|
||||||
|
/// Add a virtual monitor at a mode → [`AddReply`]. Input [`AddRequest`].
|
||||||
|
pub const IOCTL_ADD: u32 = ctl_code(0x900);
|
||||||
|
/// Remove a virtual monitor by session id. Input [`RemoveRequest`].
|
||||||
|
pub const IOCTL_REMOVE: u32 = ctl_code(0x901);
|
||||||
|
/// Pin the IddCx render adapter (hybrid-GPU IDD-push). Input [`SetRenderAdapterRequest`].
|
||||||
|
pub const IOCTL_SET_RENDER_ADAPTER: u32 = ctl_code(0x902);
|
||||||
|
/// Keepalive (resets the driver watchdog). No payload.
|
||||||
|
pub const IOCTL_PING: u32 = ctl_code(0x903);
|
||||||
|
/// Version + watchdog handshake → [`InfoReply`]. No input.
|
||||||
|
pub const IOCTL_GET_INFO: u32 = ctl_code(0x904);
|
||||||
|
/// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the
|
||||||
|
/// SudoVDA "send-and-hope-it's-ignored" hack.
|
||||||
|
pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905);
|
||||||
|
|
||||||
|
/// `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
|
||||||
|
/// mode as preferred; the host still CCD-forces the active mode (the OS activates IDDs at a default).
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AddRequest {
|
||||||
|
pub session_id: u64,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub refresh_hz: u32,
|
||||||
|
pub _reserved: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to
|
||||||
|
/// match `windows` `LUID { LowPart: u32, HighPart: i32 }`).
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AddReply {
|
||||||
|
pub adapter_luid_low: u32,
|
||||||
|
pub adapter_luid_high: i32,
|
||||||
|
pub target_id: u32,
|
||||||
|
pub _reserved: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `IOCTL_REMOVE` input.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RemoveRequest {
|
||||||
|
pub session_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `IOCTL_SET_RENDER_ADAPTER` input (the GPU the IddCx swap-chain should render on).
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SetRenderAdapterRequest {
|
||||||
|
pub luid_low: u32,
|
||||||
|
pub luid_high: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `IOCTL_GET_INFO` reply: the protocol version (asserted against [`super::PROTOCOL_VERSION`]) and
|
||||||
|
/// the watchdog timeout the host must ping within.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||||
|
pub struct InfoReply {
|
||||||
|
pub protocol_version: u32,
|
||||||
|
pub watchdog_timeout_s: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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!`
|
||||||
|
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
|
||||||
|
const _: () = {
|
||||||
|
use core::mem::{offset_of, size_of};
|
||||||
|
|
||||||
|
assert!(size_of::<AddRequest>() == 24);
|
||||||
|
assert!(offset_of!(AddRequest, session_id) == 0);
|
||||||
|
assert!(offset_of!(AddRequest, width) == 8);
|
||||||
|
assert!(offset_of!(AddRequest, height) == 12);
|
||||||
|
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
||||||
|
|
||||||
|
assert!(size_of::<AddReply>() == 16);
|
||||||
|
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
||||||
|
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
||||||
|
assert!(offset_of!(AddReply, target_id) == 8);
|
||||||
|
|
||||||
|
assert!(size_of::<RemoveRequest>() == 8);
|
||||||
|
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||||
|
|
||||||
|
assert!(size_of::<SetRenderAdapterRequest>() == 8);
|
||||||
|
assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0);
|
||||||
|
assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4);
|
||||||
|
|
||||||
|
assert!(size_of::<InfoReply>() == 8);
|
||||||
|
assert!(offset_of!(InfoReply, protocol_version) == 0);
|
||||||
|
assert!(offset_of!(InfoReply, watchdog_timeout_s) == 4);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The IDD-push frame transport: the host-created shared ring header, the publish token, the names, and
|
||||||
|
/// the driver-status codes. The texture ring itself is host-created D3D11 keyed-mutex textures (opened
|
||||||
|
/// by name on the driver side); only the *layout/contract* lives here.
|
||||||
|
pub mod frame {
|
||||||
|
use alloc::string::String;
|
||||||
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
|
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
|
||||||
|
/// only attaches to a fully-published ring.
|
||||||
|
pub const MAGIC: u32 = 0x4456_4650;
|
||||||
|
/// Frame-plane version (independent bump of the header layout).
|
||||||
|
pub const VERSION: u32 = 1;
|
||||||
|
/// Ring slots. Headroom so the driver's 0 ms-timeout publish always finds a free slot while the host
|
||||||
|
/// holds one across the convert/copy + the pipelined encode. MUST be identical on both sides — it is,
|
||||||
|
/// because both read this one constant.
|
||||||
|
pub const RING_LEN: u32 = 6;
|
||||||
|
|
||||||
|
/// `driver_status` values the driver writes into the host header (the host logs them on a timeout).
|
||||||
|
pub const DRV_STATUS_NONE: u32 = 0;
|
||||||
|
/// Driver attached to the ring and is publishing.
|
||||||
|
pub const DRV_STATUS_OPENED: u32 = 1;
|
||||||
|
/// Driver could not open the host's textures — render-adapter mismatch (it renders on a different GPU
|
||||||
|
/// than where the host created the ring). `driver_status_detail` carries the HRESULT.
|
||||||
|
pub const DRV_STATUS_TEX_FAIL: u32 = 2;
|
||||||
|
/// Driver has no `ID3D11Device1` to open shared resources.
|
||||||
|
pub const DRV_STATUS_NO_DEVICE1: u32 = 3;
|
||||||
|
|
||||||
|
/// The shared metadata header (host-created, mapped by both sides). Atomic fields (`magic`, `latest`,
|
||||||
|
/// `generation`) are accessed via each side's own atomic view over the mapping; this is the layout.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||||
|
pub struct SharedHeader {
|
||||||
|
pub magic: u32,
|
||||||
|
pub version: u32,
|
||||||
|
/// Bumped by the host on a ring recreate (HDR-mode flip → new texture format/names). The driver
|
||||||
|
/// re-attaches when it changes; a publish carries it so the host rejects a stale-ring publish.
|
||||||
|
pub generation: u32,
|
||||||
|
pub ring_len: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub dxgi_format: u32,
|
||||||
|
pub _pad: u32,
|
||||||
|
/// Driver-written after each copy; host loads `Acquire`. See [`FrameToken`].
|
||||||
|
pub latest: u64,
|
||||||
|
pub qpc_pts: u64,
|
||||||
|
/// Driver-written: the adapter the swap-chain actually renders on (mismatch detection).
|
||||||
|
pub driver_render_luid_low: u32,
|
||||||
|
pub driver_render_luid_high: i32,
|
||||||
|
/// Driver-written status (visibility channel — UMDF hides OutputDebugString + the restricted
|
||||||
|
/// token blocks file writes, so this header is how the driver reports state).
|
||||||
|
pub driver_status: u32,
|
||||||
|
pub driver_status_detail: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `SharedHeader.latest` publish token: `(generation << 40) | (seq << 8) | slot`.
|
||||||
|
/// `generation` is 24-bit, `seq` 32-bit, `slot` 8-bit. The generation tag lets the host REJECT a
|
||||||
|
/// publish from a stale ring (an old-generation publisher racing a mid-session recreate) so it never
|
||||||
|
/// consumes an unwritten new-ring slot — eliminating the toggle-time garbage frame.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct FrameToken {
|
||||||
|
pub generation: u32,
|
||||||
|
pub seq: u32,
|
||||||
|
pub slot: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameToken {
|
||||||
|
/// Low 24 bits of `generation` are significant (see the field docs).
|
||||||
|
pub const GENERATION_MASK: u32 = 0x00FF_FFFF;
|
||||||
|
|
||||||
|
pub const fn pack(self) -> u64 {
|
||||||
|
(((self.generation & Self::GENERATION_MASK) as u64) << 40)
|
||||||
|
| (((self.seq as u64) & 0xFFFF_FFFF) << 8)
|
||||||
|
| (self.slot as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn unpack(v: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
generation: ((v >> 40) as u32) & Self::GENERATION_MASK,
|
||||||
|
seq: ((v >> 8) & 0xFFFF_FFFF) as u32,
|
||||||
|
slot: (v & 0xFF) as u8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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
|
||||||
|
// 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.
|
||||||
|
const _: () = {
|
||||||
|
use core::mem::{offset_of, size_of};
|
||||||
|
|
||||||
|
assert!(size_of::<SharedHeader>() == 64);
|
||||||
|
assert!(offset_of!(SharedHeader, magic) == 0);
|
||||||
|
assert!(offset_of!(SharedHeader, version) == 4);
|
||||||
|
assert!(offset_of!(SharedHeader, generation) == 8);
|
||||||
|
assert!(offset_of!(SharedHeader, ring_len) == 12);
|
||||||
|
assert!(offset_of!(SharedHeader, width) == 16);
|
||||||
|
assert!(offset_of!(SharedHeader, height) == 20);
|
||||||
|
assert!(offset_of!(SharedHeader, dxgi_format) == 24);
|
||||||
|
assert!(offset_of!(SharedHeader, _pad) == 28);
|
||||||
|
assert!(offset_of!(SharedHeader, latest) == 32);
|
||||||
|
assert!(offset_of!(SharedHeader, qpc_pts) == 40);
|
||||||
|
assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48);
|
||||||
|
assert!(offset_of!(SharedHeader, driver_render_luid_high) == 52);
|
||||||
|
assert!(offset_of!(SharedHeader, driver_status) == 56);
|
||||||
|
assert!(offset_of!(SharedHeader, driver_status_detail) == 60);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`).
|
||||||
|
///
|
||||||
|
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
|
||||||
|
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
|
||||||
|
/// workspaces, guarded only by "must match" comments — the top ABI-drift hazard the audit flagged
|
||||||
|
/// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
|
||||||
|
/// asserts makes a one-sided edit a compile error.
|
||||||
|
///
|
||||||
|
/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can
|
||||||
|
/// open it) and the driver maps it. Layout only; the section itself is host-created shared memory.
|
||||||
|
pub mod gamepad {
|
||||||
|
use alloc::string::String;
|
||||||
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
|
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
|
||||||
|
pub const XUSB_MAGIC: u32 = 0x5558_4650;
|
||||||
|
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely
|
||||||
|
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
|
||||||
|
/// only the u32 value is the contract.)
|
||||||
|
pub const PAD_MAGIC: u32 = 0x5046_4453;
|
||||||
|
|
||||||
|
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
|
||||||
|
/// zeroed, so `0` = DualSense is the default; one driver serves either identity.
|
||||||
|
pub const DEVTYPE_DUALSENSE: u8 = 0;
|
||||||
|
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
|
||||||
|
pub const DEVTYPE_DUALSHOCK4: u8 = 1;
|
||||||
|
|
||||||
|
/// `Global\pfxusb-shm-<index>` — the virtual Xbox 360 (XInput) shared section.
|
||||||
|
pub fn xusb_shm_name(index: u8) -> String {
|
||||||
|
alloc::format!("Global\\pfxusb-shm-{index}")
|
||||||
|
}
|
||||||
|
/// `Global\pfds-shm-<index>` — the virtual DualSense / DualShock 4 shared section.
|
||||||
|
pub fn pad_shm_name(index: u8) -> String {
|
||||||
|
alloc::format!("Global\\pfds-shm-{index}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped
|
||||||
|
/// `packet` number + buttons/triggers/sticks in XInput conventions); the driver answers
|
||||||
|
/// `XInputGetState`. The driver writes force-feedback (`XInputSetState`) into `rumble_*`, bumping
|
||||||
|
/// `rumble_seq`, which the host relays to the client.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||||
|
pub struct XusbShm {
|
||||||
|
pub magic: u32,
|
||||||
|
/// XInput `dwPacketNumber` — bumped by the host on every state change.
|
||||||
|
pub packet: u32,
|
||||||
|
pub buttons: u16,
|
||||||
|
pub left_trigger: u8,
|
||||||
|
pub right_trigger: u8,
|
||||||
|
pub thumb_lx: i16,
|
||||||
|
pub thumb_ly: i16,
|
||||||
|
pub thumb_rx: i16,
|
||||||
|
pub thumb_ry: i16,
|
||||||
|
pub _reserved0: u32,
|
||||||
|
/// Bumped by the driver on a new force-feedback packet.
|
||||||
|
pub rumble_seq: u32,
|
||||||
|
pub rumble_large: u8,
|
||||||
|
pub rumble_small: u8,
|
||||||
|
pub _reserved1: [u8; 34],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID
|
||||||
|
/// input report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's
|
||||||
|
/// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) into `output`, bumping
|
||||||
|
/// `out_seq`. `device_type` selects the HID identity ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]).
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||||
|
pub struct PadShm {
|
||||||
|
pub magic: u32,
|
||||||
|
pub _reserved0: u32,
|
||||||
|
/// Input report region (host-written; the codec's report is <= 64 B — see
|
||||||
|
/// `inject::dualsense_proto::DS_INPUT_REPORT_LEN`). The region spans `magic`+pad .. `out_seq`.
|
||||||
|
pub input: [u8; 64],
|
||||||
|
/// Bumped by the driver when it publishes a new `output` report.
|
||||||
|
pub out_seq: u32,
|
||||||
|
/// Output report region (driver-written): rumble / lightbar / player-LEDs / adaptive triggers.
|
||||||
|
pub output: [u8; 64],
|
||||||
|
/// HID identity selector — see [`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`].
|
||||||
|
pub device_type: u8,
|
||||||
|
pub _reserved1: [u8; 115],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
|
||||||
|
// assert here means the struct no longer matches the historical `OFF_*` layout (host) / `view.add(N)`
|
||||||
|
// literal (driver) and must be fixed before either side switches to the type.
|
||||||
|
const _: () = {
|
||||||
|
use core::mem::{offset_of, size_of};
|
||||||
|
|
||||||
|
assert!(size_of::<XusbShm>() == 64);
|
||||||
|
assert!(offset_of!(XusbShm, magic) == 0);
|
||||||
|
assert!(offset_of!(XusbShm, packet) == 4);
|
||||||
|
assert!(offset_of!(XusbShm, buttons) == 8);
|
||||||
|
assert!(offset_of!(XusbShm, left_trigger) == 10);
|
||||||
|
assert!(offset_of!(XusbShm, right_trigger) == 11);
|
||||||
|
assert!(offset_of!(XusbShm, thumb_lx) == 12);
|
||||||
|
assert!(offset_of!(XusbShm, thumb_ly) == 14);
|
||||||
|
assert!(offset_of!(XusbShm, thumb_rx) == 16);
|
||||||
|
assert!(offset_of!(XusbShm, thumb_ry) == 18);
|
||||||
|
assert!(offset_of!(XusbShm, rumble_seq) == 24);
|
||||||
|
assert!(offset_of!(XusbShm, rumble_large) == 28);
|
||||||
|
assert!(offset_of!(XusbShm, rumble_small) == 29);
|
||||||
|
|
||||||
|
assert!(size_of::<PadShm>() == 256);
|
||||||
|
assert!(offset_of!(PadShm, magic) == 0);
|
||||||
|
assert!(offset_of!(PadShm, input) == 8);
|
||||||
|
assert!(offset_of!(PadShm, out_seq) == 72);
|
||||||
|
assert!(offset_of!(PadShm, output) == 76);
|
||||||
|
assert!(offset_of!(PadShm, device_type) == 140);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bytemuck::Zeroable;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_token_roundtrips() {
|
||||||
|
for (g, s, slot) in [
|
||||||
|
(1u32, 0u32, 0u8),
|
||||||
|
(5, 12_345, 3),
|
||||||
|
(frame::FrameToken::GENERATION_MASK, 0xFFFF_FFFF, 5),
|
||||||
|
(0, 1, 255),
|
||||||
|
] {
|
||||||
|
let t = frame::FrameToken {
|
||||||
|
generation: g,
|
||||||
|
seq: s,
|
||||||
|
slot,
|
||||||
|
};
|
||||||
|
assert_eq!(frame::FrameToken::unpack(t.pack()), t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_token_packing_matches_legacy_layout() {
|
||||||
|
// The legacy code packed (gen<<40)|(seq<<8)|slot by hand; lock the bit positions.
|
||||||
|
let t = frame::FrameToken {
|
||||||
|
generation: 7,
|
||||||
|
seq: 42,
|
||||||
|
slot: 3,
|
||||||
|
};
|
||||||
|
assert_eq!(t.pack(), (7u64 << 40) | (42u64 << 8) | 3u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_header_is_pod_and_64_bytes() {
|
||||||
|
let mut h = frame::SharedHeader::zeroed();
|
||||||
|
h.magic = frame::MAGIC;
|
||||||
|
h.width = 5120;
|
||||||
|
h.height = 1440;
|
||||||
|
let bytes = bytemuck::bytes_of(&h);
|
||||||
|
assert_eq!(bytes.len(), 64);
|
||||||
|
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
|
||||||
|
assert_eq!(back.magic, frame::MAGIC);
|
||||||
|
assert_eq!(back.width, 5120);
|
||||||
|
assert_eq!(back.height, 1440);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_structs_roundtrip_through_bytes() {
|
||||||
|
let req = control::AddRequest {
|
||||||
|
session_id: 0xDEAD_BEEF_CAFE_F00D,
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
refresh_hz: 120,
|
||||||
|
_reserved: 0,
|
||||||
|
};
|
||||||
|
let bytes = bytemuck::bytes_of(&req);
|
||||||
|
assert_eq!(bytes.len(), 24);
|
||||||
|
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_are_stable() {
|
||||||
|
assert_eq!(frame::header_name(10), "Global\\pfvd-hdr-10");
|
||||||
|
assert_eq!(frame::event_name(10), "Global\\pfvd-evt-10");
|
||||||
|
assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamepad_names_and_magics_are_stable() {
|
||||||
|
assert_eq!(gamepad::xusb_shm_name(0), "Global\\pfxusb-shm-0");
|
||||||
|
assert_eq!(gamepad::pad_shm_name(2), "Global\\pfds-shm-2");
|
||||||
|
// 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::PAD_MAGIC, 0x5046_4453);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctl_codes_are_contiguous_and_distinct() {
|
||||||
|
assert_eq!(control::IOCTL_ADD, ctl_code(0x900));
|
||||||
|
let all = [
|
||||||
|
control::IOCTL_ADD,
|
||||||
|
control::IOCTL_REMOVE,
|
||||||
|
control::IOCTL_SET_RENDER_ADAPTER,
|
||||||
|
control::IOCTL_PING,
|
||||||
|
control::IOCTL_GET_INFO,
|
||||||
|
control::IOCTL_CLEAR_ALL,
|
||||||
|
];
|
||||||
|
for (i, a) in all.iter().enumerate() {
|
||||||
|
for b in &all[i + 1..] {
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guid_is_not_sudovda() {
|
||||||
|
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
|
||||||
|
assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1454,11 +1454,16 @@ pub mod endpoint {
|
|||||||
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
|
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
|
||||||
fn stream_transport() -> Arc<quinn::TransportConfig> {
|
fn stream_transport() -> Arc<quinn::TransportConfig> {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
const MAX_IDLE: Duration = Duration::from_secs(20);
|
// 8s idle (was 20s): a vanished client is declared dead within 8s instead of 20, so its
|
||||||
|
// session tears down promptly — which the Windows IDD-push path needs so a RECONNECT recreates
|
||||||
|
// a fresh virtual monitor (a reused monitor's IddCx swap-chain dies) instead of joining the
|
||||||
|
// still-lingering old session. Active sessions are unaffected: video keeps the connection live,
|
||||||
|
// and the 4s keep-alive holds it open through quiet control periods.
|
||||||
|
const MAX_IDLE: Duration = Duration::from_secs(8);
|
||||||
const KEEP_ALIVE: Duration = Duration::from_secs(4);
|
const KEEP_ALIVE: Duration = Duration::from_secs(4);
|
||||||
let mut t = quinn::TransportConfig::default();
|
let mut t = quinn::TransportConfig::default();
|
||||||
t.max_idle_timeout(Some(
|
t.max_idle_timeout(Some(
|
||||||
quinn::IdleTimeout::try_from(MAX_IDLE).expect("20s is a valid QUIC idle timeout"),
|
quinn::IdleTimeout::try_from(MAX_IDLE).expect("8s is a valid QUIC idle timeout"),
|
||||||
));
|
));
|
||||||
t.keep_alive_interval(Some(KEEP_ALIVE));
|
t.keep_alive_interval(Some(KEEP_ALIVE));
|
||||||
Arc::new(t)
|
Arc::new(t)
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ aes-gcm = "0.10"
|
|||||||
cbc = { version = "0.1", features = ["alloc"] }
|
cbc = { version = "0.1", features = ["alloc"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
# Cover-art delivery in the game library: encode Lutris's local JPEGs into `data:` URLs and decode
|
||||||
|
# the Epic launcher's base64 `catcache.bin`. Cross-platform (Linux Lutris art + Windows Epic art).
|
||||||
|
base64 = "0.22"
|
||||||
|
# Blocking HTTP for the library cover-art warmer (no-auth GOG api.gog.com + Xbox displaycatalog),
|
||||||
|
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
|
||||||
|
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
|
||||||
|
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
|
||||||
|
ureq = "2"
|
||||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||||
x509-parser = "0.16"
|
x509-parser = "0.16"
|
||||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||||
@@ -85,6 +93,10 @@ wayland-scanner = "0.31"
|
|||||||
wayland-backend = "0.3"
|
wayland-backend = "0.3"
|
||||||
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
|
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
|
||||||
|
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
|
||||||
|
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
|
||||||
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
||||||
xkbcommon = "0.8"
|
xkbcommon = "0.8"
|
||||||
# The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream*
|
# The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream*
|
||||||
@@ -155,7 +167,7 @@ windows = { version = "0.62", features = [
|
|||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
# VirtualProtect — for the inline patch of the win32u GPU-preference shim (Apollo's MinHook port:
|
# VirtualProtect — for the inline patch of the win32u GPU-preference shim (Apollo's MinHook port:
|
||||||
# the hybrid-GPU output-reparenting hook that keeps Desktop Duplication stable on a 4090+iGPU box).
|
# the hybrid-GPU output-reparenting hook that keeps Desktop Duplication stable on a 4090+iGPU box).
|
||||||
# See capture/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
# See capture/windows/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
||||||
# crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
|
# crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
|
||||||
"Win32_System_Memory",
|
"Win32_System_Memory",
|
||||||
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
|
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
|
||||||
@@ -169,15 +181,19 @@ windows = { version = "0.62", features = [
|
|||||||
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||||
# the `windows` crate above.
|
# the `windows` crate above.
|
||||||
windows-service = "0.7"
|
windows-service = "0.7"
|
||||||
|
# Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store
|
||||||
|
# provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration.
|
||||||
|
winreg = "0.56"
|
||||||
|
# Parse each Xbox/Game-Pass game's MicrosoftGame.config (GDK manifest XML) for the Xbox store
|
||||||
|
# provider — a small read-only DOM is all we need (Identity/Executable/ShellVisuals/StoreId).
|
||||||
|
roxmltree = "0.21"
|
||||||
# Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically
|
# Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically
|
||||||
# compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path.
|
# compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path.
|
||||||
openh264 = "0.9"
|
openh264 = "0.9"
|
||||||
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
|
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
|
||||||
wasapi = "0.23"
|
wasapi = "0.23"
|
||||||
# Virtual Xbox 360 gamepad via ViGEmBus (the uinput-xpad analogue) — driver installed separately.
|
# Virtual Xbox 360 gamepad: the in-tree XUSB companion UMDF driver (packaging/windows/xusb-driver),
|
||||||
# `unstable_xtarget_notification` exposes the rumble/LED back-channel (the game's force-feedback →
|
# driven over shared memory from inject/windows/gamepad_windows.rs — no ViGEmBus dependency.
|
||||||
# `request_notification`), the analogue of the Linux uinput EV_FF read path.
|
|
||||||
vigem-client = { version = "0.1", features = ["unstable_xtarget_notification"] }
|
|
||||||
# NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with
|
# NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with
|
||||||
# `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches
|
# `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches
|
||||||
# cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how
|
# cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how
|
||||||
@@ -190,6 +206,12 @@ nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional =
|
|||||||
# same BtbN gpl-shared tree the Windows client uses) and pulls the shared `avcodec/avutil/...` DLLs
|
# same BtbN gpl-shared tree the Windows client uses) and pulls the shared `avcodec/avutil/...` DLLs
|
||||||
# at runtime. `ffmpeg-sys-next` auto-detects the FFmpeg version (7.x/avcodec-61 or 8.x/62).
|
# at runtime. `ffmpeg-sys-next` auto-detects the FFmpeg version (7.x/avcodec-61 or 8.x/62).
|
||||||
ffmpeg-next = { version = "8", optional = true }
|
ffmpeg-next = { version = "8", optional = true }
|
||||||
|
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend
|
||||||
|
# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs,
|
||||||
|
# defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs
|
||||||
|
# to/from the DeviceIoControl byte buffers.
|
||||||
|
pf-driver-proto = { path = "../pf-driver-proto" }
|
||||||
|
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, and the host then needs
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
mod linux;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "audio/windows/wasapi_cap.rs"]
|
||||||
mod wasapi_cap;
|
mod wasapi_cap;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "audio/windows/wasapi_mic.rs"]
|
||||||
mod wasapi_mic;
|
mod wasapi_mic;
|
||||||
|
|||||||
+10
@@ -13,6 +13,9 @@
|
|||||||
//! when the client isn't talking. WASAPI objects are `!Send`, so they live entirely on that thread
|
//! when the client isn't talking. WASAPI objects are `!Send`, so they live entirely on that thread
|
||||||
//! (mirrors `WasapiLoopbackCapturer`).
|
//! (mirrors `WasapiLoopbackCapturer`).
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{VirtualMic, SAMPLE_RATE};
|
use super::{VirtualMic, SAMPLE_RATE};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
@@ -154,6 +157,13 @@ fn find_or_install_device() -> Result<wasapi::Device> {
|
|||||||
Ok(d) => Ok(d),
|
Ok(d) => Ok(d),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::info!("no virtual mic device present — attempting auto-install");
|
tracing::info!("no virtual mic device present — attempting auto-install");
|
||||||
|
// SAFETY: `try_install_virtual_mic` is `unsafe` only because it `LoadLibraryExW`s
|
||||||
|
// `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer;
|
||||||
|
// calling it imposes no extra precondition here (it takes no args and aliases nothing).
|
||||||
|
// Its internal contract holds: the `DiInstall` type matches the documented
|
||||||
|
// `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a
|
||||||
|
// NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the
|
||||||
|
// dedicated mic thread.
|
||||||
if unsafe { try_install_virtual_mic() } {
|
if unsafe { try_install_virtual_mic() } {
|
||||||
find_device()
|
find_device()
|
||||||
} else {
|
} else {
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
|
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
|
||||||
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
|
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
|
||||||
|
|
||||||
|
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||||
|
// program). As a parent module this also covers the child modules (capture::windows/linux::*).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
|
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
|
||||||
@@ -44,6 +48,49 @@ impl PixelFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a Windows capturer should produce, resolved **once** per session and passed **into**
|
||||||
|
/// [`capture_virtual_output`] (Goal-1 stage 5, plan §2.3/§5). Passing the format in is what lets a
|
||||||
|
/// capturer stop re-deriving the encode backend itself — it kills the
|
||||||
|
/// `capture/dxgi.rs → encode::windows_resolved_backend()` back-reference (the highest-severity coupling:
|
||||||
|
/// capture and encode could otherwise disagree on whether frames are GPU-resident). Neutral type; the
|
||||||
|
/// Linux portal capturer ignores it (it negotiates its own format with PipeWire).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct OutputFormat {
|
||||||
|
/// Produce GPU-resident D3D11 frames (zero-copy for a GPU encoder — NVENC/AMF/QSV) rather than CPU
|
||||||
|
/// staging. `false` **only** for the GPU-less software encoder.
|
||||||
|
pub gpu: bool,
|
||||||
|
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `Rgb10a2`; the DDA secure-desktop HDR hint).
|
||||||
|
/// `false` = 8-bit SDR.
|
||||||
|
pub hdr: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputFormat {
|
||||||
|
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
|
||||||
|
/// (`crate::session_plan`) — the GameStream + spike paths: `gpu` from the resolved encode backend,
|
||||||
|
/// `hdr` as given. The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already
|
||||||
|
/// resolved the encoder), so neither path makes a capturer re-derive it.
|
||||||
|
pub fn resolve(hdr: bool) -> Self {
|
||||||
|
OutputFormat {
|
||||||
|
gpu: gpu_encode(),
|
||||||
|
hdr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single
|
||||||
|
/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn gpu_encode() -> bool {
|
||||||
|
!matches!(
|
||||||
|
crate::encode::windows_resolved_backend(),
|
||||||
|
crate::encode::WindowsBackend::Software
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
pub(crate) fn gpu_encode() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
||||||
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
|
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
|
||||||
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
|
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
|
||||||
@@ -142,6 +189,16 @@ pub trait Capturer: Send {
|
|||||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many frames the encode loop may keep in flight (submitted but not yet polled) before it
|
||||||
|
/// blocks. `1` (the default) is the synchronous loop: capture → submit → poll-blocks, so the
|
||||||
|
/// per-frame wall time is `capture+convert + encode`. A capturer that hands a fresh output texture
|
||||||
|
/// per frame (so the encode of N reads a different texture than the convert of N+1 writes) can return
|
||||||
|
/// `>1` to PIPELINE: the loop submits N+1 before polling N, overlapping the convert/copy on the 3D
|
||||||
|
/// engine with the NVENC-ASIC encode of the prior frame, dropping per-frame wall toward `max(...)`.
|
||||||
|
fn pipeline_depth(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||||
@@ -302,7 +359,14 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
|||||||
/// [`crate::vdisplay::VirtualDisplay`] backend. The captured size is the size the output was
|
/// [`crate::vdisplay::VirtualDisplay`] backend. The captured size is the size the output was
|
||||||
/// created at — native, no scaling.
|
/// created at — native, no scaling.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> {
|
pub fn capture_virtual_output(
|
||||||
|
vout: crate::vdisplay::VirtualOutput,
|
||||||
|
_want: OutputFormat,
|
||||||
|
_capture: crate::session_plan::CaptureBackend,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
|
// The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own format, so
|
||||||
|
// the `OutputFormat` is unused here; the capture backend is always the portal (the `CaptureBackend`
|
||||||
|
// arg is a Windows-only dispatch — ignored here).
|
||||||
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
|
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,11 +377,16 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
|
|||||||
/// compiled and comes back the moment the flag is unset.
|
/// compiled and comes back the moment the flag is unset.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn wgc_disabled() -> bool {
|
pub(crate) fn wgc_disabled() -> bool {
|
||||||
std::env::var_os("PUNKTFUNK_NO_WGC").is_some()
|
crate::config::config().no_wgc
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> {
|
pub fn capture_virtual_output(
|
||||||
|
vout: crate::vdisplay::VirtualOutput,
|
||||||
|
want: OutputFormat,
|
||||||
|
capture: crate::session_plan::CaptureBackend,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
|
use crate::session_plan::CaptureBackend;
|
||||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
||||||
@@ -325,16 +394,39 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
|
|||||||
})?;
|
})?;
|
||||||
let pref = vout.preferred_mode;
|
let pref = vout.preferred_mode;
|
||||||
let keep = vout.keepalive;
|
let keep = vout.keepalive;
|
||||||
|
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared
|
||||||
|
// ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
|
||||||
|
// (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
|
||||||
|
// display) so there's no fall-through.
|
||||||
|
if capture == CaptureBackend::IddPush {
|
||||||
|
// Recreate the monitor + ring per session (fix-teardown): a FRESH monitor reliably gets a
|
||||||
|
// working IddCx swap-chain, whereas a REUSED monitor's swap-chain dies after ~2 sessions and
|
||||||
|
// the host can't revive it. The driver's recreate crash (target id resolved to 0) is fixed by
|
||||||
|
// stamping target_id onto the monitor context. The ring is always FP16 (the driver composes
|
||||||
|
// the IDD in FP16); `want_hdr` selects the per-frame conversion (FP16 → Rgb10a2 vs Bgra).
|
||||||
|
// If IDD-push can't open OR the driver doesn't attach to the ring within a few seconds (e.g. a
|
||||||
|
// hybrid-GPU render mismatch), fall back to DDA so the session is NEVER left black (audit §5.1).
|
||||||
|
// `open()` hands the keepalive back on failure so DDA can take ownership of the virtual display.
|
||||||
|
match idd_push::IddPushCapturer::open(target.clone(), pref, want.hdr, keep) {
|
||||||
|
Ok(c) => return Ok(Box::new(c) as Box<dyn Capturer>),
|
||||||
|
Err((e, keep)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"IDD-push open/attach failed — falling back to DDA"
|
||||||
|
);
|
||||||
|
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||||
|
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the
|
// WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the
|
||||||
// overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug),
|
// overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug),
|
||||||
// and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is
|
// and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is
|
||||||
// the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the
|
// the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the
|
||||||
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback.
|
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
|
||||||
let backend = std::env::var("PUNKTFUNK_CAPTURE")
|
// backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
|
||||||
.unwrap_or_default()
|
if capture == CaptureBackend::Dda {
|
||||||
.to_ascii_lowercase();
|
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||||
if backend == "dda" || backend == "dxgi" || wgc_disabled() {
|
|
||||||
return dxgi::DuplCapturer::open(target, pref, keep, false)
|
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||||
}
|
}
|
||||||
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
|
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
|
||||||
@@ -345,6 +437,11 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
|
|||||||
// DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC
|
// DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC
|
||||||
// objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC
|
// objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC
|
||||||
// only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive).
|
// only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive).
|
||||||
|
// SAFETY: `RoInitialize` is a combase FFI call that initializes the WinRT apartment for the calling
|
||||||
|
// thread. It takes the `RO_INIT_MULTITHREADED` enum by value and borrows no memory, so there is no
|
||||||
|
// pointer/lifetime/aliasing obligation; it is safe on any thread and idempotent — a second call on a
|
||||||
|
// thread already in a compatible apartment returns S_FALSE / RPC_E_CHANGED_MODE, which we discard.
|
||||||
|
// Runs on the encode thread that goes on to use the WGC (WinRT) objects built by the watchdog thread.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = windows::Win32::System::WinRT::RoInitialize(
|
let _ = windows::Win32::System::WinRT::RoInitialize(
|
||||||
windows::Win32::System::WinRT::RO_INIT_MULTITHREADED,
|
windows::Win32::System::WinRT::RO_INIT_MULTITHREADED,
|
||||||
@@ -364,31 +461,45 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
|
|||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
|
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
|
||||||
dxgi::DuplCapturer::open(target, pref, keep, false)
|
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA");
|
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA");
|
||||||
dxgi::DuplCapturer::open(target, pref, keep, false)
|
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
pub fn capture_virtual_output(_vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> {
|
pub fn capture_virtual_output(
|
||||||
|
_vout: crate::vdisplay::VirtualOutput,
|
||||||
|
_want: OutputFormat,
|
||||||
|
_capture: crate::session_plan::CaptureBackend,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Goal-1 stage 6: the Windows backends live under `capture/windows/`, the Linux one under `capture/linux/`
|
||||||
|
// (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "capture/windows/composed_flip.rs"]
|
||||||
pub mod composed_flip;
|
pub mod composed_flip;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "capture/windows/desktop_watch.rs"]
|
||||||
pub mod desktop_watch;
|
pub mod desktop_watch;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "capture/windows/dxgi.rs"]
|
||||||
pub mod dxgi;
|
pub mod dxgi;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "capture/windows/idd_push.rs"]
|
||||||
|
pub mod idd_push;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
mod linux;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "capture/windows/wgc.rs"]
|
||||||
pub mod wgc;
|
pub mod wgc;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "capture/windows/wgc_relay.rs"]
|
||||||
pub mod wgc_relay;
|
pub mod wgc_relay;
|
||||||
|
|||||||
+77
@@ -17,6 +17,9 @@
|
|||||||
//! instead of leaking it to process exit. The portal thread (when used) still parks on its zbus
|
//! instead of leaking it to process exit. The portal thread (when used) still parks on its zbus
|
||||||
//! connection until process exit.
|
//! connection until process exit.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat};
|
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use std::os::fd::OwnedFd;
|
use std::os::fd::OwnedFd;
|
||||||
@@ -498,6 +501,12 @@ mod pipewire {
|
|||||||
|
|
||||||
impl DmabufMap {
|
impl DmabufMap {
|
||||||
fn new(fd: i32, len: usize) -> Option<DmabufMap> {
|
fn new(fd: i32, len: usize) -> Option<DmabufMap> {
|
||||||
|
// SAFETY: a null `addr` lets the kernel choose the mapping address; `fd` is a caller-owned
|
||||||
|
// dmabuf/MemFd fd, valid for the duration of this call, and `len` is the requested map length.
|
||||||
|
// `mmap` reads no Rust memory — it installs a fresh PROT_READ/MAP_SHARED page mapping and
|
||||||
|
// returns its base (or MAP_FAILED, checked below before `DmabufMap` adopts it). The returned
|
||||||
|
// region is a brand-new VMA, so it aliases no live Rust object, and it keeps the underlying
|
||||||
|
// object mapped independently of `fd` (which may be closed after this returns).
|
||||||
let ptr = unsafe {
|
let ptr = unsafe {
|
||||||
libc::mmap(
|
libc::mmap(
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
@@ -514,6 +523,11 @@ mod pipewire {
|
|||||||
|
|
||||||
impl Drop for DmabufMap {
|
impl Drop for DmabufMap {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.ptr`/`self.len` are exactly the base+length of a successful `mmap` in
|
||||||
|
// `DmabufMap::new` (constructed only when `ptr != MAP_FAILED`). This `DmabufMap` uniquely owns
|
||||||
|
// that mapping and `drop` runs once, so `munmap` releases a live mapping exactly once — no
|
||||||
|
// double-unmap. Every `&[u8]` derived from the mapping is bounded by this `DmabufMap`'s
|
||||||
|
// lifetime, so no borrow outlives the unmap.
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::munmap(self.ptr, self.len);
|
libc::munmap(self.ptr, self.len);
|
||||||
}
|
}
|
||||||
@@ -719,6 +733,14 @@ mod pipewire {
|
|||||||
if !ud.active.load(Ordering::Relaxed) {
|
if !ud.active.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
|
||||||
|
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
|
||||||
|
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
|
||||||
|
// before forming any slice. `(*spa_buf).datas` points to `n_datas` libspa `spa_data` structs, and
|
||||||
|
// `pw::spa::buffer::Data` is `#[repr(transparent)]` over `spa_data` (the same cast
|
||||||
|
// `Buffer::datas_mut` performs — see the function doc), so the pointer cast + length describe
|
||||||
|
// exactly that array, in bounds. The PipeWire loop is single-threaded and owns the buffer here, so
|
||||||
|
// this `&mut` slice is the only reference to it (no aliasing/data race).
|
||||||
let datas: &mut [pw::spa::buffer::Data] = unsafe {
|
let datas: &mut [pw::spa::buffer::Data] = unsafe {
|
||||||
if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() {
|
if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() {
|
||||||
&mut []
|
&mut []
|
||||||
@@ -783,6 +805,10 @@ mod pipewire {
|
|||||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||||
// imports it. (Content stability across the brief map+CSC window relies on
|
// imports it. (Content stability across the brief map+CSC window relies on
|
||||||
// the compositor's buffer-pool depth, like any zero-copy capture.)
|
// the compositor's buffer-pool depth, like any zero-copy capture.)
|
||||||
|
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||||
|
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||||
|
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||||
|
// The original stays owned by PipeWire; the dup is a new fd we own (checked >= 0).
|
||||||
let dup =
|
let dup =
|
||||||
unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) };
|
unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) };
|
||||||
if dup >= 0 {
|
if dup >= 0 {
|
||||||
@@ -796,6 +822,10 @@ mod pipewire {
|
|||||||
pts_ns,
|
pts_ns,
|
||||||
format: fmt,
|
format: fmt,
|
||||||
payload: FramePayload::Dmabuf(DmabufFrame {
|
payload: FramePayload::Dmabuf(DmabufFrame {
|
||||||
|
// SAFETY: `dup` is the fresh fd `fcntl(F_DUPFD_CLOEXEC)` just returned
|
||||||
|
// (checked `dup >= 0`); nothing else owns it, so `OwnedFd` takes sole
|
||||||
|
// ownership and closes it exactly once on drop — no alias, no
|
||||||
|
// double-close.
|
||||||
fd: unsafe { OwnedFd::from_raw_fd(dup) },
|
fd: unsafe { OwnedFd::from_raw_fd(dup) },
|
||||||
fourcc,
|
fourcc,
|
||||||
modifier: ud.modifier,
|
modifier: ud.modifier,
|
||||||
@@ -930,6 +960,11 @@ mod pipewire {
|
|||||||
// cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process —
|
// cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process —
|
||||||
// trust `d.data()`.
|
// trust `d.data()`.
|
||||||
let fd_len = if raw_fd > 0 {
|
let fd_len = if raw_fd > 0 {
|
||||||
|
// SAFETY: `libc::stat` is a C plain-old-data struct for which all-zero is a valid value, so
|
||||||
|
// `mem::zeroed()` is a sound initializer. `raw_fd` is the buffer's fd (`> 0` checked here) and
|
||||||
|
// valid for this callback; `fstat` writes metadata into `&mut st`, a live, aligned,
|
||||||
|
// correctly-sized stack `stat` that outlives the synchronous call. `st.st_size` is read only
|
||||||
|
// after the return value is confirmed `== 0`. `st` is a fresh local, so nothing aliases it.
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut st: libc::stat = std::mem::zeroed();
|
let mut st: libc::stat = std::mem::zeroed();
|
||||||
(libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0)
|
(libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0)
|
||||||
@@ -946,6 +981,14 @@ mod pipewire {
|
|||||||
match DmabufMap::new(raw_fd as i32, map_len) {
|
match DmabufMap::new(raw_fd as i32, map_len) {
|
||||||
Some(m) => {
|
Some(m) => {
|
||||||
_mapping = m;
|
_mapping = m;
|
||||||
|
// SAFETY: `_mapping` is the `DmabufMap` just stored; its `ptr`/`len` come from a
|
||||||
|
// successful `mmap` of `map_len` PROT_READ bytes, so `ptr` is non-null, page-aligned,
|
||||||
|
// and the VMA is one allocated object of `len` bytes valid for reads. In the common
|
||||||
|
// path `map_len == fd_len` (the fd's real size from `fstat`), so the mapping spans the
|
||||||
|
// whole object; the de-pad copy below is further bounded by the `offset <= buf.len()`
|
||||||
|
// and `needed > avail` guards. The `&[u8]` borrows `_mapping`, which lives to the end
|
||||||
|
// of `consume_frame`, so the slice never outlives the mapping, and the memory is only
|
||||||
|
// read here, so there is no aliasing/mutation.
|
||||||
Some(unsafe {
|
Some(unsafe {
|
||||||
std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len)
|
std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len)
|
||||||
})
|
})
|
||||||
@@ -1177,24 +1220,43 @@ mod pipewire {
|
|||||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
|
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
|
||||||
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
|
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
|
||||||
// queued buffers, requeue the older ones, keep only the newest.
|
// queued buffers, requeue the older ones, keep only the newest.
|
||||||
|
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
|
||||||
|
// the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
|
||||||
|
// a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
|
||||||
|
// null-checked before any use. The loop is single-threaded, so no concurrent access.
|
||||||
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
||||||
if newest.is_null() {
|
if newest.is_null() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut drained = 1u32;
|
let mut drained = 1u32;
|
||||||
loop {
|
loop {
|
||||||
|
// SAFETY: same stream/loop-thread contract as the dequeue above; each call returns
|
||||||
|
// the next stream-owned `*mut pw_buffer` or null (null-checked before use).
|
||||||
let next = unsafe { stream.dequeue_raw_buffer() };
|
let next = unsafe { stream.dequeue_raw_buffer() };
|
||||||
if next.is_null() {
|
if next.is_null() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
|
||||||
|
// stream and not yet requeued; `pw_stream_queue_buffer` hands ownership back to the
|
||||||
|
// stream. We immediately overwrite `newest = next`, so the requeued pointer is never
|
||||||
|
// touched again (no use-after-requeue). Loop thread, single-threaded.
|
||||||
unsafe { stream.queue_raw_buffer(newest) };
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
newest = next;
|
newest = next;
|
||||||
drained += 1;
|
drained += 1;
|
||||||
}
|
}
|
||||||
|
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
|
||||||
|
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
|
||||||
|
// load through a valid pointer — no mutation or aliasing.
|
||||||
let spa_buf = unsafe { (*newest).buffer };
|
let spa_buf = unsafe { (*newest).buffer };
|
||||||
|
|
||||||
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
||||||
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
|
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
|
||||||
|
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the buffer we still hold.
|
||||||
|
// `spa_buffer_find_meta_data` scans that buffer's metadata array for a `SPA_META_Header`
|
||||||
|
// of at least `size_of::<spa_meta_header>()` bytes and returns a pointer into the held
|
||||||
|
// buffer's metadata (or null). The size argument matches the struct the result is cast
|
||||||
|
// to, and the pointer stays valid as long as the buffer is held (until requeue). Null is
|
||||||
|
// handled below.
|
||||||
let hdr = unsafe {
|
let hdr = unsafe {
|
||||||
spa::sys::spa_buffer_find_meta_data(
|
spa::sys::spa_buffer_find_meta_data(
|
||||||
spa_buf,
|
spa_buf,
|
||||||
@@ -1205,11 +1267,20 @@ mod pipewire {
|
|||||||
let hdr_flags = if hdr.is_null() {
|
let hdr_flags = if hdr.is_null() {
|
||||||
0u32
|
0u32
|
||||||
} else {
|
} else {
|
||||||
|
// SAFETY: reached only when `hdr` is non-null; it points to a `spa_meta_header`
|
||||||
|
// inside the live buffer's metadata (returned for a size >=
|
||||||
|
// `size_of::<spa_meta_header>()`, so `.flags` is in bounds). A single field read
|
||||||
|
// while the buffer is still held.
|
||||||
unsafe { (*hdr).flags }
|
unsafe { (*hdr).flags }
|
||||||
};
|
};
|
||||||
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
|
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
|
||||||
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
|
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
|
||||||
// stale skip only applies to mappable SHM buffers).
|
// stale skip only applies to mappable SHM buffers).
|
||||||
|
// SAFETY: every dereference is guarded in order before any field read — `spa_buf`
|
||||||
|
// non-null, `n_datas > 0`, the `datas` (`*mut spa_data`) array non-null, and the first
|
||||||
|
// element's `chunk` (`*mut spa_chunk`) non-null. `d0` is that first `spa_data` and `c`
|
||||||
|
// its chunk; reading `(*d0).type_`, `(*c).size`, `(*c).flags` are in-bounds field loads
|
||||||
|
// of libspa structs inside the buffer we still hold. Single-threaded loop, no mutation.
|
||||||
let (chunk_size, chunk_flags, is_dmabuf) = unsafe {
|
let (chunk_size, chunk_flags, is_dmabuf) = unsafe {
|
||||||
if !spa_buf.is_null()
|
if !spa_buf.is_null()
|
||||||
&& (*spa_buf).n_datas > 0
|
&& (*spa_buf).n_datas > 0
|
||||||
@@ -1246,11 +1317,17 @@ mod pipewire {
|
|||||||
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// SAFETY: `newest` is the non-null buffer we own (dequeued, never requeued on this
|
||||||
|
// skip path); hand it back to the stream exactly once and return without touching it
|
||||||
|
// again. Loop thread inside `.process`.
|
||||||
unsafe { stream.queue_raw_buffer(newest) };
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
consume_frame(ud, spa_buf);
|
consume_frame(ud, spa_buf);
|
||||||
|
// SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
|
||||||
|
// from `newest`), so requeuing the owned `newest` exactly once here is sound — no
|
||||||
|
// use-after-requeue. Loop thread inside `.process`.
|
||||||
unsafe { stream.queue_raw_buffer(newest) };
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
}));
|
}));
|
||||||
if outcome.is_err() {
|
if outcome.is_err() {
|
||||||
+10
@@ -15,6 +15,9 @@
|
|||||||
//! composed while a session is live). Effectiveness can be build/driver-dependent; gated by
|
//! composed while a session is live). Effectiveness can be build/driver-dependent; gated by
|
||||||
//! `PUNKTFUNK_FORCE_COMPOSED` (default ON; set =0 to disable).
|
//! `PUNKTFUNK_FORCE_COMPOSED` (default ON; set =0 to disable).
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use windows::core::w;
|
use windows::core::w;
|
||||||
@@ -48,6 +51,10 @@ impl ForceComposedFlip {
|
|||||||
let st = stop.clone();
|
let st = stop.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("composed-flip".into())
|
.name("composed-flip".into())
|
||||||
|
// SAFETY: `run` is this module's `unsafe fn` (it owns a desktop+window lifecycle via Win32
|
||||||
|
// FFI); it takes ownership of `st` (the stop `Arc<AtomicBool>`) and has no caller-side memory
|
||||||
|
// precondition. It is designed to own its thread for its whole duration — exactly the
|
||||||
|
// dedicated `composed-flip` thread spawned here.
|
||||||
.spawn(move || unsafe { run(st) })
|
.spawn(move || unsafe { run(st) })
|
||||||
.ok()?;
|
.ok()?;
|
||||||
tracing::info!("force-composed-flip overlay started (Winlogon-aware)");
|
tracing::info!("force-composed-flip overlay started (Winlogon-aware)");
|
||||||
@@ -62,6 +69,9 @@ impl Drop for ForceComposedFlip {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
|
extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
|
||||||
|
// SAFETY: this is the window procedure the OS invokes with the window's own `hwnd` and a real
|
||||||
|
// message `(msg, wp, lp)`. `DefWindowProcW` performs default processing for exactly those
|
||||||
|
// parameters (all passed straight through by value); it borrows no Rust memory and is synchronous.
|
||||||
unsafe { DefWindowProcW(hwnd, msg, wp, lp) }
|
unsafe { DefWindowProcW(hwnd, msg, wp, lp) }
|
||||||
}
|
}
|
||||||
|
|
||||||
+11
-1
@@ -1,5 +1,5 @@
|
|||||||
//! Input-desktop watcher (Windows) — the authoritative "normal vs secure desktop" signal for the
|
//! Input-desktop watcher (Windows) — the authoritative "normal vs secure desktop" signal for the
|
||||||
//! two-process secure-desktop design (docs/windows-secure-desktop.md).
|
//! two-process secure-desktop design (design/archive/windows-secure-desktop.md).
|
||||||
//!
|
//!
|
||||||
//! Windows switches the *input desktop* to "Winlogon" (the secure desktop) for UAC elevation, the
|
//! Windows switches the *input desktop* to "Winlogon" (the secure desktop) for UAC elevation, the
|
||||||
//! lock screen and the login screen, and back to "Default" for the normal session. WGC captures only
|
//! lock screen and the login screen, and back to "Default" for the normal session. WGC captures only
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
//! desktop's NAME (WTS session notifications miss UAC entirely, so the name is the reliable signal)
|
//! desktop's NAME (WTS session notifications miss UAC entirely, so the name is the reliable signal)
|
||||||
//! and publishes it as an atomic the capture mux + input path read.
|
//! and publishes it as an atomic the capture mux + input path read.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -33,6 +36,10 @@ impl DesktopWatcher {
|
|||||||
// mux) sees the real state immediately. Otherwise a session that begins already on the secure
|
// mux) sees the real state immediately. Otherwise a session that begins already on the secure
|
||||||
// desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll
|
// desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll
|
||||||
// interval and relay one stale normal-desktop frame — the "flash of the login screen" bug.
|
// interval and relay one stale normal-desktop frame — the "flash of the login screen" bug.
|
||||||
|
// SAFETY: `is_secure_desktop` is this module's `unsafe fn` — unsafe only because it calls Win32
|
||||||
|
// desktop FFI (`OpenInputDesktop`/`GetUserObjectInformationW`/`CloseDesktop`), with no caller
|
||||||
|
// precondition; it opens, names, and closes the input-desktop handle internally and is safe to
|
||||||
|
// call from any thread (here, on the thread running `DesktopWatcher::start`).
|
||||||
let initial = if unsafe { is_secure_desktop() } {
|
let initial = if unsafe { is_secure_desktop() } {
|
||||||
DESKTOP_SECURE
|
DESKTOP_SECURE
|
||||||
} else {
|
} else {
|
||||||
@@ -53,6 +60,9 @@ impl DesktopWatcher {
|
|||||||
let mut candidate = initial;
|
let mut candidate = initial;
|
||||||
let mut stable = 0u32;
|
let mut stable = 0u32;
|
||||||
while !st.load(Ordering::Relaxed) {
|
while !st.load(Ordering::Relaxed) {
|
||||||
|
// SAFETY: same as in `start` — `is_secure_desktop` is self-contained Win32 desktop
|
||||||
|
// FFI with no caller precondition, called here on the dedicated `desktop-watch`
|
||||||
|
// polling thread.
|
||||||
let v = if unsafe { is_secure_desktop() } {
|
let v = if unsafe { is_secure_desktop() } {
|
||||||
DESKTOP_SECURE
|
DESKTOP_SECURE
|
||||||
} else {
|
} else {
|
||||||
+173
-56
@@ -7,6 +7,9 @@
|
|||||||
//! Validates only with a real GPU + an *activated* SudoVDA monitor (`DuplicateOutput` needs a live
|
//! Validates only with a real GPU + an *activated* SudoVDA monitor (`DuplicateOutput` needs a live
|
||||||
//! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there.
|
//! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
@@ -69,7 +72,12 @@ pub struct D3d11Frame {
|
|||||||
pub texture: ID3D11Texture2D,
|
pub texture: ID3D11Texture2D,
|
||||||
pub device: ID3D11Device,
|
pub device: ID3D11Device,
|
||||||
}
|
}
|
||||||
// COM pointers, used only from the single owning thread.
|
// SAFETY: `D3d11Frame` owns an `ID3D11Texture2D` + `ID3D11Device`, which are COM interface pointers.
|
||||||
|
// D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is
|
||||||
|
// created free-threaded (`make_device` passes no `D3D11_CREATE_DEVICE_SINGLETHREADED`), so handing
|
||||||
|
// ownership of the frame to another thread — the capture→encode handoff — and releasing it there is
|
||||||
|
// sound. The value is moved, never aliased (no `Sync`), so there is no concurrent use of the
|
||||||
|
// single-threaded immediate context.
|
||||||
unsafe impl Send for D3d11Frame {}
|
unsafe impl Send for D3d11Frame {}
|
||||||
|
|
||||||
pub fn pack_luid(luid: LUID) -> i64 {
|
pub fn pack_luid(luid: LUID) -> i64 {
|
||||||
@@ -202,41 +210,35 @@ pub(crate) unsafe fn make_device(
|
|||||||
Ok((device, context))
|
Ok((device, context))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apollo-style GPU scheduling-priority hardening (Sunshine `display_base.cpp:599-709`). On a
|
/// Resolve the configured GPU scheduling-priority class from `PUNKTFUNK_GPU_PRIORITY_CLASS`
|
||||||
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
|
/// (`off|normal|high|realtime`, default high). `None` = leave it at the OS default (the `off` opt-out).
|
||||||
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
|
/// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4, REALTIME 5.
|
||||||
/// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority`
|
fn configured_gpu_priority_class() -> Option<i32> {
|
||||||
/// alone, which we measured as no help) lets our brief encode preempt the game. Uses HIGH, NOT
|
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
|
||||||
/// realtime: realtime on NVIDIA + HAGS can freeze/crash NVENC (Apollo downgrades it for exactly this).
|
.ok()
|
||||||
/// Runs once per process; best-effort. `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime`
|
.as_deref()
|
||||||
/// (default high).
|
{
|
||||||
fn elevate_process_gpu_priority() {
|
Some("off") => None,
|
||||||
use std::sync::Once;
|
Some("normal") => Some(2),
|
||||||
static ONCE: Once = Once::new();
|
Some("realtime") => Some(5),
|
||||||
ONCE.call_once(|| unsafe {
|
_ => Some(4), // HIGH — safe on NVIDIA+HAGS (realtime can freeze NVENC)
|
||||||
use windows::core::{s, PCWSTR};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable SE_INC_BASE_PRIORITY on the CURRENT process token (best-effort) — the kernel gates the
|
||||||
|
/// HIGH/REALTIME GPU scheduling-priority bump on it. Held by SYSTEM/Administrators; a UAC-FILTERED
|
||||||
|
/// token (what `CreateProcessAsUserW` hands the WGC helper) does NOT have it, which is why the helper
|
||||||
|
/// can't elevate itself and the SYSTEM host stamps the class onto it cross-process instead (see
|
||||||
|
/// [`set_child_gpu_priority_class`]).
|
||||||
|
unsafe fn enable_inc_base_priority() {
|
||||||
|
use windows::core::PCWSTR;
|
||||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||||
use windows::Win32::Security::{
|
use windows::Win32::Security::{
|
||||||
AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES,
|
AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES,
|
||||||
SE_INC_BASE_PRIORITY_NAME, SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES,
|
SE_INC_BASE_PRIORITY_NAME, SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES,
|
||||||
TOKEN_PRIVILEGES, TOKEN_QUERY,
|
TOKEN_QUERY,
|
||||||
};
|
};
|
||||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
|
||||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||||
|
|
||||||
// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4,
|
|
||||||
// REALTIME 5.
|
|
||||||
let prio: i32 = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS").ok().as_deref() {
|
|
||||||
Some("off") => {
|
|
||||||
tracing::info!("GPU process scheduling priority class left at default (off)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Some("normal") => 2,
|
|
||||||
Some("realtime") => 5,
|
|
||||||
_ => 4, // HIGH — safe on NVIDIA+HAGS (realtime can freeze NVENC)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. Enable SE_INC_BASE_PRIORITY so the kernel permits the GPU priority bump.
|
|
||||||
let mut token = HANDLE::default();
|
let mut token = HANDLE::default();
|
||||||
if OpenProcessToken(
|
if OpenProcessToken(
|
||||||
GetCurrentProcess(),
|
GetCurrentProcess(),
|
||||||
@@ -269,29 +271,97 @@ fn elevate_process_gpu_priority() {
|
|||||||
}
|
}
|
||||||
let _ = CloseHandle(token);
|
let _ = CloseHandle(token);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. D3DKMTSetProcessSchedulingPriorityClass via gdi32 (no stable windows-rs binding).
|
/// Call `gdi32!D3DKMTSetProcessSchedulingPriorityClass(process, prio)` (no stable windows-rs binding —
|
||||||
if let Ok(gdi32) = LoadLibraryA(s!("gdi32.dll")) {
|
/// loaded by name). Returns the NTSTATUS (0 = success) or `None` if the export can't be resolved. The
|
||||||
if let Some(p) = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass")) {
|
/// CALLING process must hold SE_INC_BASE_PRIORITY ([`enable_inc_base_priority`]) for HIGH/REALTIME; the
|
||||||
|
/// kernel checks the caller's privilege whether the target is self or a child we created.
|
||||||
|
unsafe fn d3dkmt_set_scheduling_priority_class(
|
||||||
|
process: windows::Win32::Foundation::HANDLE,
|
||||||
|
prio: i32,
|
||||||
|
) -> Option<i32> {
|
||||||
|
use windows::core::s;
|
||||||
|
use windows::Win32::Foundation::HANDLE;
|
||||||
|
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||||
|
let gdi32 = LoadLibraryA(s!("gdi32.dll")).ok()?;
|
||||||
|
let p = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass"))?;
|
||||||
type SetPrio = unsafe extern "system" fn(HANDLE, i32) -> i32;
|
type SetPrio = unsafe extern "system" fn(HANDLE, i32) -> i32;
|
||||||
let f: SetPrio = std::mem::transmute(p);
|
let f: SetPrio = std::mem::transmute(p);
|
||||||
let st = f(GetCurrentProcess(), prio);
|
Some(f(process, prio))
|
||||||
if st == 0 {
|
}
|
||||||
tracing::info!(
|
|
||||||
|
/// Apollo-style GPU scheduling-priority hardening (Sunshine `display_base.cpp:599-709`). On a
|
||||||
|
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
|
||||||
|
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
|
||||||
|
/// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority`
|
||||||
|
/// alone, which we measured as no help) lets our brief encode preempt the game. Uses HIGH, NOT
|
||||||
|
/// realtime: realtime on NVIDIA + HAGS can freeze/crash NVENC (Apollo downgrades it for exactly this).
|
||||||
|
/// Runs once per process; best-effort. `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime`
|
||||||
|
/// (default high). NOTE: in the SYSTEM-host + user-session-helper deployment this self-set NO-OPs in
|
||||||
|
/// the helper (filtered token), so the host also sets it on the helper via [`set_child_gpu_priority_class`].
|
||||||
|
fn elevate_process_gpu_priority() {
|
||||||
|
use std::sync::Once;
|
||||||
|
static ONCE: Once = Once::new();
|
||||||
|
// SAFETY: the closure calls two of this module's `unsafe fn`s — `enable_inc_base_priority`
|
||||||
|
// (adjusts the current-process token; it has no caller precondition and builds all its FFI args
|
||||||
|
// locally) and `d3dkmt_set_scheduling_priority_class` (loads gdi32 by name and calls the export).
|
||||||
|
// The latter requires `process` to be a valid process handle; `GetCurrentProcess()` returns the
|
||||||
|
// current-process pseudo-handle, which is always valid and needs no close. Runs once via
|
||||||
|
// `Once::call_once`; no raw pointers are dereferenced here.
|
||||||
|
ONCE.call_once(|| unsafe {
|
||||||
|
use windows::Win32::System::Threading::GetCurrentProcess;
|
||||||
|
let Some(prio) = configured_gpu_priority_class() else {
|
||||||
|
tracing::info!("GPU process scheduling priority class left at default (off)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
enable_inc_base_priority();
|
||||||
|
match d3dkmt_set_scheduling_priority_class(GetCurrentProcess(), prio) {
|
||||||
|
Some(0) => tracing::info!(
|
||||||
priority_class = prio,
|
priority_class = prio,
|
||||||
"GPU process scheduling priority class set (2=normal 4=high 5=realtime)"
|
"GPU process scheduling priority class set (2=normal 4=high 5=realtime)"
|
||||||
);
|
),
|
||||||
} else {
|
Some(st) => tracing::warn!(
|
||||||
tracing::warn!(
|
|
||||||
status = format!("0x{st:08X}"),
|
status = format!("0x{st:08X}"),
|
||||||
"D3DKMTSetProcessSchedulingPriorityClass failed (run as admin/SYSTEM for GPU priority)"
|
"D3DKMTSetProcessSchedulingPriorityClass failed (run as admin/SYSTEM for GPU priority)"
|
||||||
);
|
),
|
||||||
}
|
None => tracing::warn!("D3DKMTSetProcessSchedulingPriorityClass export not found"),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the GPU scheduling-priority class of ANOTHER process we created — the WGC capture+encode helper
|
||||||
|
/// in the interactive user session. The helper is spawned with the user's UAC-FILTERED token, which
|
||||||
|
/// lacks SE_INC_BASE_PRIORITY, so its own [`elevate_process_gpu_priority`] silently no-ops and NVENC
|
||||||
|
/// gets starved under a GPU-saturating game (the "240→40 fps in-game collapse"). The SYSTEM host DOES
|
||||||
|
/// hold the privilege, so it stamps the class onto the child's process handle right after spawn — the
|
||||||
|
/// process-level class applies to GPU contexts the child creates afterwards. Best-effort; logged.
|
||||||
|
/// `PUNKTFUNK_GPU_PRIORITY_CLASS=off` disables it (same knob as the self path).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `process` must be a valid handle to a process we own with at least PROCESS_SET_INFORMATION access
|
||||||
|
/// (the just-created helper, `PROCESS_INFORMATION::hProcess`).
|
||||||
|
pub(crate) unsafe fn set_child_gpu_priority_class(process: windows::Win32::Foundation::HANDLE) {
|
||||||
|
let Some(prio) = configured_gpu_priority_class() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
enable_inc_base_priority(); // the SYSTEM host holds SE_INC_BASE_PRIORITY; the helper does not
|
||||||
|
match d3dkmt_set_scheduling_priority_class(process, prio) {
|
||||||
|
Some(0) => tracing::info!(
|
||||||
|
priority_class = prio,
|
||||||
|
"WGC helper GPU scheduling priority class set cross-process from the SYSTEM host \
|
||||||
|
(2=normal 4=high 5=realtime)"
|
||||||
|
),
|
||||||
|
Some(st) => tracing::warn!(
|
||||||
|
status = format!("0x{st:08X}"),
|
||||||
|
"cross-process D3DKMTSetProcessSchedulingPriorityClass on the WGC helper failed"
|
||||||
|
),
|
||||||
|
None => tracing::warn!(
|
||||||
|
"D3DKMTSetProcessSchedulingPriorityClass export not found — WGC helper has no GPU priority"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-find the output, make a fresh device on its adapter, and duplicate it. Used by the ACCESS_LOST
|
/// Re-find the output, make a fresh device on its adapter, and duplicate it. Used by the ACCESS_LOST
|
||||||
/// recovery to rebuild the whole capture on the current (possibly secure) input desktop.
|
/// recovery to rebuild the whole capture on the current (possibly secure) input desktop.
|
||||||
unsafe fn reopen_duplication(
|
unsafe fn reopen_duplication(
|
||||||
@@ -482,6 +552,17 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
|
|||||||
pub(crate) fn install_gpu_pref_hook() {
|
pub(crate) fn install_gpu_pref_hook() {
|
||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
static HOOK: Once = Once::new();
|
static HOOK: Once = Once::new();
|
||||||
|
// SAFETY: this one-time hook install only touches a region it has just validated.
|
||||||
|
// `LoadLibraryA("win32u.dll")` + `GetProcAddress("NtGdiDdDDIGetCachedHybridQueryValue")` yield the
|
||||||
|
// live base of the real exported function, so `target` is a valid executable code pointer to at
|
||||||
|
// least the 12 bytes the patch overwrites (an x64 prologue, per Apollo's verified hook). The two
|
||||||
|
// `ptr::copy_nonoverlapping`s each move exactly 12 bytes between the 12-byte stack arrays
|
||||||
|
// (`patch`/`readback`) and `target`, which `VirtualProtect(target, 12, PAGE_EXECUTE_READWRITE, …)`
|
||||||
|
// has just made writable (and is restored to `old` after) — source and dest never overlap (stack
|
||||||
|
// vs. loaded module image), so every access stays in mapped, in-bounds memory.
|
||||||
|
// `FlushInstructionCache` gets the current-process pseudo-handle + that same range. The DPI calls
|
||||||
|
// take by-value context handles / fill the live local `&mut old`/`&mut restore` for the duration of
|
||||||
|
// each synchronous call. Runs once via `Once::call_once`, before any DXGI use.
|
||||||
HOOK.call_once(|| unsafe {
|
HOOK.call_once(|| unsafe {
|
||||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||||
use windows::Win32::System::Memory::{
|
use windows::Win32::System::Memory::{
|
||||||
@@ -1333,6 +1414,14 @@ pub fn hdr_p010_selftest() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
||||||
|
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
||||||
|
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
||||||
|
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
||||||
|
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
||||||
|
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
||||||
|
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||||
|
// proven individually at the `read_u16` closure below.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
|
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
|
||||||
let mut device: Option<ID3D11Device> = None;
|
let mut device: Option<ID3D11Device> = None;
|
||||||
@@ -1982,7 +2071,11 @@ pub struct DuplCapturer {
|
|||||||
dbg_cursor: u64,
|
dbg_cursor: u64,
|
||||||
_keepalive: Box<dyn Send>,
|
_keepalive: Box<dyn Send>,
|
||||||
}
|
}
|
||||||
// COM objects used only from the one thread that owns the capturer (the encode thread).
|
// SAFETY: `DuplCapturer` holds D3D11 device/context/duplication COM pointers plus plain data. The
|
||||||
|
// device is created free-threaded (`make_device` sets no `D3D11_CREATE_DEVICE_SINGLETHREADED`) and
|
||||||
|
// COM reference counting is interlocked, so moving ownership of the whole capturer to another thread
|
||||||
|
// is sound. It is used by exactly one thread (the encode thread) at a time — moved to it once, never
|
||||||
|
// shared (no `Sync`) — so the single-threaded immediate context is never touched concurrently.
|
||||||
unsafe impl Send for DuplCapturer {}
|
unsafe impl Send for DuplCapturer {}
|
||||||
|
|
||||||
impl DuplCapturer {
|
impl DuplCapturer {
|
||||||
@@ -1990,8 +2083,18 @@ impl DuplCapturer {
|
|||||||
target: WinCaptureTarget,
|
target: WinCaptureTarget,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
keepalive: Box<dyn Send>,
|
keepalive: Box<dyn Send>,
|
||||||
|
// Whether the (already-resolved) encode backend wants GPU-resident frames — passed IN (Goal-1
|
||||||
|
// stage 5) so the capturer never re-derives the encode backend itself.
|
||||||
|
gpu: bool,
|
||||||
want_hdr: bool,
|
want_hdr: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
|
// SAFETY: runs on the capture thread that will own this `DuplCapturer`. `install_gpu_pref_hook()`
|
||||||
|
// and the DPI-context calls take by-value handles / no args and touch only thread/process state;
|
||||||
|
// `SetThreadExecutionState` takes a flags bitmask by value. `CreateDXGIFactory1` yields a live
|
||||||
|
// `IDXGIFactory1`, and every subsequent COM method (`EnumAdapters1`/`EnumOutputs`/`GetDesc1`/
|
||||||
|
// `GetDesc`/`cast`) is called on that factory or on an adapter/output it returned — each obtained
|
||||||
|
// through a checked `while let Ok(..)`/`?` — all from this one thread. No raw pointers are
|
||||||
|
// dereferenced; the borrowed strings/locals outlive each synchronous call.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs
|
// Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs
|
||||||
// (the cause of the 0x887A0026 ACCESS_LOST churn on this hybrid box: RTX 4090 + AMD iGPU).
|
// (the cause of the 0x887A0026 ACCESS_LOST churn on this hybrid box: RTX 4090 + AMD iGPU).
|
||||||
@@ -2127,9 +2230,9 @@ impl DuplCapturer {
|
|||||||
let context = context.context("null D3D11 context")?;
|
let context = context.context("null D3D11 context")?;
|
||||||
// 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can
|
// 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can
|
||||||
// be the Winlogon secure desktop) so a session that starts at the lock/login screen works.
|
// be the Winlogon secure desktop) so a session that starts at the lock/login screen works.
|
||||||
// The SudoVDA is kept the sole desktop via the CCD isolation in sudovda::create_monitor
|
// The virtual display is kept the sole desktop via the CCD isolation the pf-vdisplay backend
|
||||||
// (registry-persisted), so the secure desktop has nowhere to render but the output we
|
// applies at monitor creation (registry-persisted), so the secure desktop has nowhere to render
|
||||||
// capture — no per-open re-isolation needed.
|
// but the output we capture — no per-open re-isolation needed.
|
||||||
attach_input_desktop();
|
attach_input_desktop();
|
||||||
let dupl = duplicate_output(&output, &device, want_hdr)
|
let dupl = duplicate_output(&output, &device, want_hdr)
|
||||||
.context("DuplicateOutput (already duplicated by another app?)")?;
|
.context("DuplicateOutput (already duplicated by another app?)")?;
|
||||||
@@ -2157,14 +2260,13 @@ impl DuplCapturer {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or((2000 / refresh_hz.max(1)).max(100));
|
.unwrap_or((2000 / refresh_hz.max(1)).max(100));
|
||||||
// Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV
|
// Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV backends
|
||||||
// backends read back / import) whenever the resolved encode backend is a GPU one — so the
|
// read back / import) whenever the encode backend is a GPU one — so the capturer's output
|
||||||
// capturer's output format matches the encoder's input. Only the software (GPU-less) path
|
// format matches the encoder's input. Only the software (GPU-less) path takes CPU staging.
|
||||||
// takes CPU staging. Mirrors `encode::open_video`'s dispatch exactly.
|
// The decision is resolved ONCE per session and passed in (Goal-1 stage 5), instead of this
|
||||||
let gpu_mode = !matches!(
|
// capturer re-calling `encode::windows_resolved_backend()` — the back-reference that let
|
||||||
crate::encode::windows_resolved_backend(),
|
// capture and encode disagree (plan §2.3/§5).
|
||||||
crate::encode::WindowsBackend::Software
|
let gpu_mode = gpu;
|
||||||
);
|
|
||||||
// Read the source display's HDR mastering metadata while we still hold `output` (it is
|
// Read the source display's HDR mastering metadata while we still hold `output` (it is
|
||||||
// moved into the struct below). Only meaningful for an HDR (FP16) duplication.
|
// moved into the struct below). Only meaningful for an HDR (FP16) duplication.
|
||||||
let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
|
let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
|
||||||
@@ -2656,7 +2758,7 @@ impl DuplCapturer {
|
|||||||
}
|
}
|
||||||
// The SudoVDA output's GDI name can CHANGE across a secure-desktop topology rebuild —
|
// The SudoVDA output's GDI name can CHANGE across a secure-desktop topology rebuild —
|
||||||
// re-resolve from the STABLE target id so we find it under its current name.
|
// re-resolve from the STABLE target id so we find it under its current name.
|
||||||
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(self.target_id) {
|
if let Some(n) = crate::win_display::resolve_gdi_name(self.target_id) {
|
||||||
self.gdi_name = n;
|
self.gdi_name = n;
|
||||||
}
|
}
|
||||||
// Re-sync the capture thread to the CURRENT input desktop on EVERY rebuild — symmetric for
|
// Re-sync the capture thread to the CURRENT input desktop on EVERY rebuild — symmetric for
|
||||||
@@ -3149,6 +3251,11 @@ impl Capturer for DuplCapturer {
|
|||||||
// the duplication up to 12 s). Better a few seconds of frozen-last-frame than dropping the stream.
|
// the duplication up to 12 s). Better a few seconds of frozen-last-frame than dropping the stream.
|
||||||
let mut deadline = Instant::now() + Duration::from_secs(20);
|
let mut deadline = Instant::now() + Duration::from_secs(20);
|
||||||
loop {
|
loop {
|
||||||
|
// SAFETY: `acquire` is an `unsafe fn` because it drives the D3D11 immediate context + the
|
||||||
|
// output duplication, which must be touched only from the capturer's owning thread.
|
||||||
|
// `next_frame` runs on that one thread — `DuplCapturer` is `Send` but not `Sync`, so it is
|
||||||
|
// owned by a single (encode) thread for its whole life — and `&mut self` gives exclusive
|
||||||
|
// access for the call, satisfying that contract.
|
||||||
if let Some(f) = unsafe { self.acquire() }? {
|
if let Some(f) = unsafe { self.acquire() }? {
|
||||||
self.ever_got_frame = true;
|
self.ever_got_frame = true;
|
||||||
return Ok(f);
|
return Ok(f);
|
||||||
@@ -3195,6 +3302,8 @@ impl Capturer for DuplCapturer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
||||||
|
// SAFETY: as in `next_frame` — `acquire` must run on the capturer's single owning thread, and
|
||||||
|
// `try_latest` is called on it (`DuplCapturer` is `Send`, not `Sync`); `&mut self` is exclusive.
|
||||||
unsafe { self.acquire() }
|
unsafe { self.acquire() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3206,11 +3315,19 @@ impl Capturer for DuplCapturer {
|
|||||||
impl Drop for DuplCapturer {
|
impl Drop for DuplCapturer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if self.holding_frame {
|
if self.holding_frame {
|
||||||
|
// SAFETY: `self.dupl` is the live `IDXGIOutputDuplication` this capturer created and owns;
|
||||||
|
// `ReleaseFrame` is a valid COM method on it, called only when `holding_frame` records that a
|
||||||
|
// frame was acquired and not yet released (so it is not an unbalanced release). Drop runs on
|
||||||
|
// whichever thread owns the capturer — its sole owner, since it is `!Sync` — and the `&`
|
||||||
|
// borrow of the duplication outlives this synchronous call.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
|
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Release the display/system-required execution state we took at open().
|
// Release the display/system-required execution state we took at open().
|
||||||
|
// SAFETY: `SetThreadExecutionState` is a Win32 FFI call taking an execution-state flag bitmask
|
||||||
|
// by value (`ES_CONTINUOUS` clears the display/system-required state taken at open); it borrows
|
||||||
|
// no Rust memory and is safe to call from any thread.
|
||||||
unsafe {
|
unsafe {
|
||||||
SetThreadExecutionState(ES_CONTINUOUS);
|
SetThreadExecutionState(ES_CONTINUOUS);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+32
-2
@@ -16,6 +16,9 @@
|
|||||||
//! Limitation: WGC cannot capture the secure desktop (lock / UAC / login) — the caller falls back to
|
//! Limitation: WGC cannot capture the secure desktop (lock / UAC / login) — the caller falls back to
|
||||||
//! the DDA backend ([`super::dxgi::DuplCapturer`]) for those (see capture.rs).
|
//! the DDA backend ([`super::dxgi::DuplCapturer`]) for those (see capture.rs).
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::dxgi::{
|
use super::dxgi::{
|
||||||
find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter,
|
find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter,
|
||||||
HdrP010Converter, VideoConverter, WinCaptureTarget,
|
HdrP010Converter, VideoConverter, WinCaptureTarget,
|
||||||
@@ -92,6 +95,10 @@ struct Deimpersonate(Option<HANDLE>);
|
|||||||
impl Drop for Deimpersonate {
|
impl Drop for Deimpersonate {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(tok) = self.0.take() {
|
if let Some(tok) = self.0.take() {
|
||||||
|
// SAFETY: `RevertToSelf` takes no arguments and undoes the thread impersonation set during
|
||||||
|
// WGC activation; `tok` is the impersonation token `HANDLE` from `impersonate_active_user`,
|
||||||
|
// owned by this `Deimpersonate` and closed exactly once here (taken out of the `Option`, so
|
||||||
|
// no double-close). Both are FFI calls borrowing no Rust memory.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = RevertToSelf();
|
let _ = RevertToSelf();
|
||||||
let _ = CloseHandle(tok);
|
let _ = CloseHandle(tok);
|
||||||
@@ -174,7 +181,12 @@ pub struct WgcCapturer {
|
|||||||
_keepalive: Option<Box<dyn Send>>,
|
_keepalive: Option<Box<dyn Send>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// COM + WinRT pointers; confined to the single owning (encode) thread, like DuplCapturer.
|
// SAFETY: like `DuplCapturer`. `WgcCapturer` holds D3D11 (free-threaded device/context) plus WGC WinRT
|
||||||
|
// objects (`Direct3D11CaptureFramePool` etc., created free-threaded via `CreateFreeThreaded`). COM/WinRT
|
||||||
|
// reference counting is interlocked, and the capturer is owned + used by exactly one encode thread,
|
||||||
|
// moved to it once and never shared (no `Sync`), so transferring ownership across threads is sound. The
|
||||||
|
// free-threaded `FrameArrived` callback touches only the `Arc<WgcSignal>` (itself `Send + Sync`), not
|
||||||
|
// the capturer's COM fields.
|
||||||
unsafe impl Send for WgcCapturer {}
|
unsafe impl Send for WgcCapturer {}
|
||||||
|
|
||||||
impl WgcCapturer {
|
impl WgcCapturer {
|
||||||
@@ -182,6 +194,15 @@ impl WgcCapturer {
|
|||||||
/// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the
|
/// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the
|
||||||
/// keepalive with the caller to hand to the DDA fallback.
|
/// keepalive with the caller to hand to the DDA fallback.
|
||||||
pub fn open(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) -> Result<Self> {
|
pub fn open(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) -> Result<Self> {
|
||||||
|
// SAFETY: runs on the thread opening the WGC session. `RoInitialize` inits this thread's WinRT
|
||||||
|
// apartment (idempotent; result ignored). `impersonate_active_user()` and `find_output()` are
|
||||||
|
// this module's `unsafe fn`s whose contracts (call on the activating thread; pass a GDI name)
|
||||||
|
// are met, and the impersonation is reverted by `_deimp`'s Drop on every return path. Every
|
||||||
|
// COM/WinRT call thereafter operates on an object obtained + `?`-checked earlier in this same
|
||||||
|
// block on this single thread — the `IDXGIOutput1` from `find_output`, the device/context from
|
||||||
|
// `make_device`, the factory/interop/item/pool/session — and the `TypedEventHandler` closure
|
||||||
|
// captures an `Arc<WgcSignal>` (Send+Sync) by move. No raw pointers are dereferenced; borrowed
|
||||||
|
// locals outlive their synchronous calls.
|
||||||
unsafe {
|
unsafe {
|
||||||
// WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem
|
// WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem
|
||||||
// activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized"
|
// activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized"
|
||||||
@@ -196,7 +217,7 @@ impl WgcCapturer {
|
|||||||
// The SudoVDA output appears a beat after the display is created — settle-retry like DDA.
|
// The SudoVDA output appears a beat after the display is created — settle-retry like DDA.
|
||||||
let deadline = Instant::now() + Duration::from_millis(2000);
|
let deadline = Instant::now() + Duration::from_millis(2000);
|
||||||
let (adapter, output) = loop {
|
let (adapter, output) = loop {
|
||||||
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(target.target_id) {
|
if let Some(n) = crate::win_display::resolve_gdi_name(target.target_id) {
|
||||||
if let Ok(found) = find_output(&n) {
|
if let Ok(found) = find_output(&n) {
|
||||||
break found;
|
break found;
|
||||||
}
|
}
|
||||||
@@ -585,6 +606,15 @@ impl WgcCapturer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn process_frame(&mut self, frame: Direct3D11CaptureFrame) -> Result<CapturedFrame> {
|
fn process_frame(&mut self, frame: Direct3D11CaptureFrame) -> Result<CapturedFrame> {
|
||||||
|
// SAFETY: runs on the capturer's single owning thread. `frame` is a live
|
||||||
|
// `Direct3D11CaptureFrame` from `self.pool`; `frame.Surface().cast::<IDirect3DDxgiInterfaceAccess
|
||||||
|
// >().GetInterface()` yields the frame's backing `ID3D11Texture2D`, which belongs to
|
||||||
|
// `self.device` (the pool was created on it via `CreateDirect3D11DeviceFromDXGIDevice`). Every
|
||||||
|
// helper called here — `hdr_to_p010`, `convert_to_yuv`, `ensure_fp16_src`, `ensure_out_ring`,
|
||||||
|
// `HdrConverter::convert`, `CopyResource`, `CreateRenderTargetView` — operates on
|
||||||
|
// `self.device`/`self.context` and that same-device texture, so all resources share one device.
|
||||||
|
// The frame is held in `self.held` until its async GPU read completes for the zero-copy paths.
|
||||||
|
// Single-threaded immediate-context use; borrowed textures/SRVs/RTVs outlive each synchronous call.
|
||||||
unsafe {
|
unsafe {
|
||||||
let surface = frame.Surface().context("frame Surface")?;
|
let surface = frame.Surface().context("frame Surface")?;
|
||||||
let access: IDirect3DDxgiInterfaceAccess = surface
|
let access: IDirect3DDxgiInterfaceAccess = surface
|
||||||
+48
-3
@@ -1,5 +1,5 @@
|
|||||||
//! Host-side WGC helper relay (Windows two-process secure-desktop design,
|
//! Host-side WGC helper relay (Windows two-process secure-desktop design,
|
||||||
//! docs/windows-secure-desktop.md — step 4).
|
//! design/archive/windows-secure-desktop.md — step 4).
|
||||||
//!
|
//!
|
||||||
//! WGC won't activate under the SYSTEM account, so the SYSTEM host can't capture the normal desktop
|
//! WGC won't activate under the SYSTEM account, so the SYSTEM host can't capture the normal desktop
|
||||||
//! itself. Instead it spawns `punktfunk-host wgc-helper` in the **interactive user session** (so WGC works)
|
//! itself. Instead it spawns `punktfunk-host wgc-helper` in the **interactive user session** (so WGC works)
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
//! Wire framing (must match `wgc_helper::write_au`): per AU
|
//! Wire framing (must match `wgc_helper::write_au`): per AU
|
||||||
//! `[u32 magic "PFAU" LE][u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
|
//! `[u32 magic "PFAU" LE][u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use crate::capture::dxgi::WinCaptureTarget;
|
use crate::capture::dxgi::WinCaptureTarget;
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use std::io::{BufRead, BufReader, Read};
|
use std::io::{BufRead, BufReader, Read};
|
||||||
@@ -56,9 +59,15 @@ pub struct HelperRelay {
|
|||||||
rx: Receiver<RelayAu>,
|
rx: Receiver<RelayAu>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// HANDLEs are just kernel handle values; we own them for the relay's lifetime and close them on Drop.
|
// SAFETY: every field is itself `Send`: the `proc`/`thread` `HANDLE`s are process-global kernel
|
||||||
|
// handle values (plain integers valid from any thread, owned for the relay's lifetime and closed once
|
||||||
|
// on Drop), `stdin_w` is a `Mutex<HANDLE>`, and `rx` is an mpsc `Receiver<RelayAu>` (which is `Send`).
|
||||||
|
// The relay is moved to one thread and owned there, so transferring it across threads is sound.
|
||||||
unsafe impl Send for HelperRelay {}
|
unsafe impl Send for HelperRelay {}
|
||||||
unsafe impl Sync for HelperRelay {}
|
// NOTE: `HelperRelay` is deliberately NOT `Sync`. Its `rx: Receiver<RelayAu>` is `!Sync` (std mpsc
|
||||||
|
// is single-consumer), and the relay is only ever a single-owner local in the punktfunk1 two-process
|
||||||
|
// mux loop — never shared by `&` across threads — so `Sync` is neither sound nor needed. (A prior
|
||||||
|
// `unsafe impl Sync` here asserted more than the fields support; removed.)
|
||||||
|
|
||||||
/// Control byte on the helper's stdin: force the next encoded frame to be an IDR (client decode
|
/// Control byte on the helper's stdin: force the next encoded frame to be an IDR (client decode
|
||||||
/// recovery). Mirrors `enc.request_keyframe()` in the single-process path.
|
/// recovery). Mirrors `enc.request_keyframe()` in the single-process path.
|
||||||
@@ -84,6 +93,10 @@ impl HelperRelay {
|
|||||||
);
|
);
|
||||||
tracing::info!(cmd = %cmdline, "spawning WGC helper in user session");
|
tracing::info!(cmd = %cmdline, "spawning WGC helper in user session");
|
||||||
|
|
||||||
|
// SAFETY: `spawn_inner` is an `unsafe fn` only because it drives raw Win32 token/pipe/process
|
||||||
|
// FFI; it imposes no caller-side memory precondition beyond valid arguments. `cmdline` is a live
|
||||||
|
// `&str` borrowed for the synchronous call and `(w, h, hz)` are plain `u32`s. It validates its
|
||||||
|
// own runtime requirements (active console session, SYSTEM token) and returns `Err` otherwise.
|
||||||
unsafe { spawn_inner(&cmdline, w, h, hz) }
|
unsafe { spawn_inner(&cmdline, w, h, hz) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +121,11 @@ impl HelperRelay {
|
|||||||
pub fn request_keyframe(&self) {
|
pub fn request_keyframe(&self) {
|
||||||
let h = self.stdin_w.lock().unwrap();
|
let h = self.stdin_w.lock().unwrap();
|
||||||
let mut written = 0u32;
|
let mut written = 0u32;
|
||||||
|
// SAFETY: `*h` is the host's write end of the helper's stdin pipe — a live `HANDLE` owned by
|
||||||
|
// this `HelperRelay` (held under the `stdin_w` Mutex, locked here), closed only in Drop.
|
||||||
|
// `WriteFile` reads the 1-byte `&[CTL_KEYFRAME]` buffer and writes the byte count into
|
||||||
|
// `written`; both are live locals that outlive the synchronous call. A failure (helper gone) is
|
||||||
|
// discarded as documented.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = windows::Win32::Storage::FileSystem::WriteFile(
|
let _ = windows::Win32::Storage::FileSystem::WriteFile(
|
||||||
*h,
|
*h,
|
||||||
@@ -121,6 +139,10 @@ impl HelperRelay {
|
|||||||
|
|
||||||
impl Drop for HelperRelay {
|
impl Drop for HelperRelay {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.proc`/`self.thread` are the child process/thread `HANDLE`s from
|
||||||
|
// `CreateProcessAsUserW`, and `stdin_w` is the host's pipe write end — all owned by this
|
||||||
|
// `HelperRelay` and closed exactly once here in Drop (no double-close). `TerminateProcess` and
|
||||||
|
// the three `CloseHandle`s are FFI calls taking those handles by value, borrowing no Rust memory.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Terminate the child first so its WGC capture + NVENC session tear down, then close our
|
// Terminate the child first so its WGC capture + NVENC session tear down, then close our
|
||||||
// handles (the reader threads end on the resulting broken pipe).
|
// handles (the reader threads end on the resulting broken pipe).
|
||||||
@@ -278,6 +300,13 @@ unsafe fn spawn_inner(cmdline: &str, w: u32, h: u32, hz: u32) -> Result<HelperRe
|
|||||||
}
|
}
|
||||||
tracing::info!(pid = pi.dwProcessId, mode = %format!("{w}x{h}@{hz}"), "WGC helper spawned");
|
tracing::info!(pid = pi.dwProcessId, mode = %format!("{w}x{h}@{hz}"), "WGC helper spawned");
|
||||||
|
|
||||||
|
// The helper does the WGC capture + NVENC encode, but it runs under the user's UAC-FILTERED token
|
||||||
|
// (no SE_INC_BASE_PRIORITY), so it can't raise its OWN GPU scheduling-priority class — under a
|
||||||
|
// GPU-saturating game NVENC then gets starved (the "240→40 fps in-game collapse"). The SYSTEM host
|
||||||
|
// holds the privilege, so stamp the HIGH GPU priority class onto the child here, right after spawn
|
||||||
|
// (the process-level class applies to the GPU contexts the helper creates afterwards).
|
||||||
|
crate::capture::dxgi::set_child_gpu_priority_class(pi.hProcess);
|
||||||
|
|
||||||
// stderr → host tracing, line by line.
|
// stderr → host tracing, line by line.
|
||||||
let err_handle = HandleReader(err_r);
|
let err_handle = HandleReader(err_r);
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
@@ -357,10 +386,17 @@ fn au_reader(mut r: HandleReader, tx: SyncSender<RelayAu>) {
|
|||||||
|
|
||||||
/// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on HANDLE).
|
/// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on HANDLE).
|
||||||
struct HandleReader(HANDLE);
|
struct HandleReader(HANDLE);
|
||||||
|
// SAFETY: `HandleReader` owns a single pipe `HANDLE` (a process-global kernel handle value, valid from
|
||||||
|
// any thread). It is moved into the dedicated reader thread and used only there (and closed once on
|
||||||
|
// Drop), never shared — so transferring ownership across threads is sound.
|
||||||
unsafe impl Send for HandleReader {}
|
unsafe impl Send for HandleReader {}
|
||||||
impl Read for HandleReader {
|
impl Read for HandleReader {
|
||||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
let mut read = 0u32;
|
let mut read = 0u32;
|
||||||
|
// SAFETY: `self.0` is the live read end of an anonymous pipe owned by this `HandleReader`
|
||||||
|
// (closed only in Drop). `ReadFile` fills the caller-provided `buf` (writing at most `buf.len()`
|
||||||
|
// bytes) and stores the count in `read`; both outlive the synchronous call. A broken pipe
|
||||||
|
// surfaces as `Err` and is mapped to EOF below.
|
||||||
let ok = unsafe {
|
let ok = unsafe {
|
||||||
windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None)
|
windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None)
|
||||||
};
|
};
|
||||||
@@ -373,6 +409,8 @@ impl Read for HandleReader {
|
|||||||
}
|
}
|
||||||
impl Drop for HandleReader {
|
impl Drop for HandleReader {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.0` is the pipe `HANDLE` this `HandleReader` owns; `CloseHandle` (an FFI call
|
||||||
|
// taking the handle by value) is invoked exactly once here in Drop, so there is no double-close.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = CloseHandle(self.0);
|
let _ = CloseHandle(self.0);
|
||||||
}
|
}
|
||||||
@@ -384,6 +422,13 @@ impl Drop for HandleReader {
|
|||||||
pub fn running_as_system() -> bool {
|
pub fn running_as_system() -> bool {
|
||||||
use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
|
use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
|
||||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||||
|
// SAFETY: `OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)` opens the current-process
|
||||||
|
// token (the pseudo-handle is always valid) into `token`, which is closed once before each return.
|
||||||
|
// The first `GetTokenInformation` (null buffer) queries the required `len`; `buf` is then a
|
||||||
|
// `Vec<u8>` of exactly `len` bytes and the second call fills it, so `&*(buf.as_ptr() as *const
|
||||||
|
// TOKEN_USER)` reads a `TOKEN_USER` the kernel just wrote into a sufficiently-sized buffer (the
|
||||||
|
// variable-length SID it points at also lies within `buf`, which outlives the borrow).
|
||||||
|
// `is_local_system_sid` is this module's `unsafe fn`, given that in-buffer `PSID`. Safe on any thread.
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut token = HANDLE::default();
|
let mut token = HANDLE::default();
|
||||||
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
|
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! `HostConfig` — the host's runtime knobs parsed ONCE from the environment, instead of the ~68 scattered
|
||||||
|
//! `env::var` reads recomputed at every call site (some up to 8×, which lets capture + encode silently
|
||||||
|
//! disagree on the resolved backend — plan §2.4). The service / launcher loads `host.env` into the process
|
||||||
|
//! environment before the host starts, and **for the knobs captured here the environment is constant for the
|
||||||
|
//! process lifetime**, so a lazily-parsed global is equivalent to "parsed once at startup".
|
||||||
|
//!
|
||||||
|
//! **Goal-1 stages 1–2** (`design/windows-host-rewrite.md` §2.2): stage 1 stood this up; stage 2 migrated the
|
||||||
|
//! genuinely-constant operator/dispatch knobs onto it (the dispatch-disagreement bug class: `idd_push`,
|
||||||
|
//! `capture_backend`, `encoder_pref`, `render_adapter`, `no_wgc`, the vdisplay backend select — plus the
|
||||||
|
//! plan-named `secure_dda`/`idd_depth`/`zerocopy`/`ten_bit` and the multi-site `perf`/`compositor`/
|
||||||
|
//! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the
|
||||||
|
//! capture/topology/encoder decision.
|
||||||
|
//!
|
||||||
|
//! **What is deliberately NOT here (and must stay a live `env::var` read):**
|
||||||
|
//! - **Runtime-mutated session vars.** On Linux, [`crate::vdisplay::apply_session_env`] rewrites the process
|
||||||
|
//! env on *every connect* so one host follows a Bazzite box across Gaming↔Desktop: `WAYLAND_DISPLAY`,
|
||||||
|
//! `XDG_CURRENT_DESKTOP`, `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`, and the *derived* `PUNKTFUNK_*`
|
||||||
|
//! vars `INPUT_BACKEND`, `GAMESCOPE_SESSION`/`GAMESCOPE_NODE`, `KWIN_VIRTUAL_PRIMARY`,
|
||||||
|
//! `MUTTER_VIRTUAL_PRIMARY`, `FORCE_SHM` (+ `GAMESCOPE_APP` on the launch path). Parsing these once would
|
||||||
|
//! freeze them at startup and silently break session-following — they are NOT constant.
|
||||||
|
//! - **Single-use local tuning** read exactly where it is used (no resolve-once benefit, and a parse with a
|
||||||
|
//! call-site-local default/clamp): e.g. `FEC_PCT` (two *different* semantics — GameStream default-20 vs
|
||||||
|
//! punktfunk/1 `Option`/clamp-90), `VIDEO_DROP`, `VBV_FRAMES`, `SPLIT_ENCODE`, `PACE_BURST_KB`, the
|
||||||
|
//! `capture/dxgi.rs` timing knobs, the `*_LIVE` test gates.
|
||||||
|
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
|
||||||
|
//! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`.
|
||||||
|
//!
|
||||||
|
//! `PUNKTFUNK_ZEROCOPY` note: this field uses **presence** semantics (`var_os(..).is_some()`) to match the
|
||||||
|
//! Windows `encode/ffmpeg_win.rs` reader. The Linux `zerocopy` module keeps its own *truthy* parser
|
||||||
|
//! (`1|true|yes|on`) — the two are independent features that share a name; do NOT conflate them.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
|
||||||
|
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
|
||||||
|
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HostConfig {
|
||||||
|
/// `PUNKTFUNK_IDD_PUSH` — capture from the pf-vdisplay driver's shared ring (in-process Session-0
|
||||||
|
/// capture; no WGC helper). **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on); unset ⇒ off.
|
||||||
|
/// The installer's default `host.env` sets it on, so a fresh install runs the validated IDD-push path
|
||||||
|
/// (it falls back to DDA if the driver can't attach — see [`crate::capture`]). NOT a bare presence flag
|
||||||
|
/// (so an operator can turn it OFF in `host.env` with `=0`, which a `var_os` presence check can't).
|
||||||
|
pub idd_push: bool,
|
||||||
|
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
|
||||||
|
pub encoder_pref: String,
|
||||||
|
/// `PUNKTFUNK_NO_HELPER` — never spawn the user-session WGC helper.
|
||||||
|
pub no_helper: bool,
|
||||||
|
/// `PUNKTFUNK_FORCE_HELPER` — force the WGC helper even when not running as SYSTEM.
|
||||||
|
pub force_helper: bool,
|
||||||
|
/// `PUNKTFUNK_NO_WGC` — force the pure single-process DDA path (skip WGC and the two-process relay).
|
||||||
|
pub no_wgc: bool,
|
||||||
|
/// `PUNKTFUNK_CAPTURE` — explicit Windows capture-backend override (lowercased; `dda`/`dxgi` vs the WGC default).
|
||||||
|
pub capture_backend: String,
|
||||||
|
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
|
||||||
|
/// the empty string still counts as "set" for the presence checks, and the value reader filters it).
|
||||||
|
pub render_adapter: Option<String>,
|
||||||
|
/// `PUNKTFUNK_SECURE_DDA` — enable the experimental DDA-on-secure-desktop (Winlogon/UAC) mux leg.
|
||||||
|
pub secure_dda: bool,
|
||||||
|
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
|
||||||
|
pub idd_depth: usize,
|
||||||
|
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
|
||||||
|
pub zerocopy: bool,
|
||||||
|
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
||||||
|
pub ten_bit: bool,
|
||||||
|
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
||||||
|
pub perf: bool,
|
||||||
|
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||||
|
pub video_source: Option<String>,
|
||||||
|
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
||||||
|
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||||
|
pub compositor: Option<String>,
|
||||||
|
/// `PUNKTFUNK_GAMEPAD` — client/operator virtual-pad backend preference (fed to `pick_gamepad`).
|
||||||
|
pub gamepad: Option<String>,
|
||||||
|
/// `PUNKTFUNK_VDISPLAY` — Windows virtual-display backend. The pf-vdisplay IddCx driver is now the only
|
||||||
|
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
||||||
|
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||||
|
pub vdisplay: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostConfig {
|
||||||
|
fn from_env() -> Self {
|
||||||
|
// Presence flag: set ⇒ true. Matches the original `var_os(k).is_some()` reads (and the few
|
||||||
|
// `var(k).is_ok()` flag reads, which coincide for every real-world value).
|
||||||
|
let flag = |k: &str| std::env::var_os(k).is_some();
|
||||||
|
// String value: `var(k).ok()` — `Some` (possibly empty) when set with valid UTF-8, else `None`.
|
||||||
|
let val = |k: &str| std::env::var(k).ok();
|
||||||
|
Self {
|
||||||
|
// Value-aware (not a bare presence flag): the shipped default `host.env` turns it ON, and an
|
||||||
|
// operator turns it OFF with `PUNKTFUNK_IDD_PUSH=0` (a `var_os` presence check would read `=0`
|
||||||
|
// as "on"). Unset ⇒ off (the dev / non-pf-driver default).
|
||||||
|
idd_push: match std::env::var("PUNKTFUNK_IDD_PUSH") {
|
||||||
|
Ok(v) => !matches!(
|
||||||
|
v.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"" | "0" | "false" | "no" | "off"
|
||||||
|
),
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase(),
|
||||||
|
no_helper: flag("PUNKTFUNK_NO_HELPER"),
|
||||||
|
force_helper: flag("PUNKTFUNK_FORCE_HELPER"),
|
||||||
|
no_wgc: flag("PUNKTFUNK_NO_WGC"),
|
||||||
|
capture_backend: std::env::var("PUNKTFUNK_CAPTURE")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase(),
|
||||||
|
render_adapter: val("PUNKTFUNK_RENDER_ADAPTER"),
|
||||||
|
secure_dda: flag("PUNKTFUNK_SECURE_DDA"),
|
||||||
|
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
||||||
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
|
.unwrap_or(2),
|
||||||
|
zerocopy: flag("PUNKTFUNK_ZEROCOPY"),
|
||||||
|
ten_bit: flag("PUNKTFUNK_10BIT"),
|
||||||
|
perf: flag("PUNKTFUNK_PERF"),
|
||||||
|
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||||
|
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||||
|
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||||
|
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide host configuration, parsed once on first access.
|
||||||
|
pub fn config() -> &'static HostConfig {
|
||||||
|
static CFG: OnceLock<HostConfig> = OnceLock::new();
|
||||||
|
CFG.get_or_init(HostConfig::from_env)
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
//! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input
|
//! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input
|
||||||
//! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a
|
//! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a
|
||||||
//! VA surface). One [`Encoder`] trait, selected in [`open_video`].
|
//! VA surface). One [`Encoder`] trait, selected in [`open_video`].
|
||||||
|
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||||
|
// program). As a parent module this also covers the child modules (encode::windows/linux::*).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use crate::capture::{CapturedFrame, PixelFormat};
|
use crate::capture::{CapturedFrame, PixelFormat};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -71,9 +74,34 @@ impl Codec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
||||||
|
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
||||||
|
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct EncoderCaps {
|
||||||
|
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||||
|
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
|
||||||
|
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
|
||||||
|
/// Only the Windows direct-NVENC path implements RFI; libavcodec (Linux NVENC), VAAPI and
|
||||||
|
/// AMF/QSV always keyframe.
|
||||||
|
pub supports_rfi: bool,
|
||||||
|
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||||
|
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||||
|
/// Windows direct-NVENC path attaches it today.
|
||||||
|
pub supports_hdr_metadata: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// A hardware encoder. One per session; runs on the encode thread.
|
/// A hardware encoder. One per session; runs on the encode thread.
|
||||||
pub trait Encoder: Send {
|
pub trait Encoder: Send {
|
||||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||||
|
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||||
|
/// route by query rather than rely on the no-op/`false` defaults of
|
||||||
|
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||||
|
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
||||||
|
/// path overrides it.
|
||||||
|
fn caps(&self) -> EncoderCaps {
|
||||||
|
EncoderCaps::default()
|
||||||
|
}
|
||||||
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
|
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
|
||||||
/// reference-frame-invalidation request). Default: no-op.
|
/// reference-frame-invalidation request). Default: no-op.
|
||||||
fn request_keyframe(&mut self) {}
|
fn request_keyframe(&mut self) {}
|
||||||
@@ -173,14 +201,12 @@ pub fn open_video(
|
|||||||
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
||||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||||
// its errors crisply instead of silently trying the other).
|
// its errors crisply instead of silently trying the other).
|
||||||
let pref = std::env::var("PUNKTFUNK_ENCODER")
|
let pref = crate::config::config().encoder_pref.as_str();
|
||||||
.unwrap_or_default()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let open_vaapi = || -> Result<Box<dyn Encoder>> {
|
let open_vaapi = || -> Result<Box<dyn Encoder>> {
|
||||||
vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth)
|
vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth)
|
||||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||||
};
|
};
|
||||||
match pref.as_str() {
|
match pref {
|
||||||
"nvenc" | "nvidia" | "cuda" => open_nvenc_probed(
|
"nvenc" | "nvidia" | "cuda" => open_nvenc_probed(
|
||||||
codec,
|
codec,
|
||||||
format,
|
format,
|
||||||
@@ -379,11 +405,7 @@ fn nvidia_present() -> bool {
|
|||||||
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn linux_zero_copy_is_vaapi() -> bool {
|
pub fn linux_zero_copy_is_vaapi() -> bool {
|
||||||
match std::env::var("PUNKTFUNK_ENCODER")
|
match crate::config::config().encoder_pref.as_str() {
|
||||||
.unwrap_or_default()
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
.as_str()
|
|
||||||
{
|
|
||||||
"nvenc" | "nvidia" | "cuda" => false,
|
"nvenc" | "nvidia" | "cuda" => false,
|
||||||
"vaapi" | "amd" | "intel" => true,
|
"vaapi" | "amd" | "intel" => true,
|
||||||
_ => !nvidia_present(),
|
_ => !nvidia_present(),
|
||||||
@@ -450,10 +472,8 @@ enum GpuVendor {
|
|||||||
/// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree.
|
/// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
||||||
let pref = std::env::var("PUNKTFUNK_ENCODER")
|
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
|
||||||
.unwrap_or_default()
|
match crate::config::config().encoder_pref.as_str() {
|
||||||
.to_ascii_lowercase();
|
|
||||||
match pref.as_str() {
|
|
||||||
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
|
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
|
||||||
"amf" | "amd" => WindowsBackend::Amf,
|
"amf" | "amd" => WindowsBackend::Amf,
|
||||||
"qsv" | "intel" => WindowsBackend::Qsv,
|
"qsv" | "intel" => WindowsBackend::Qsv,
|
||||||
@@ -488,6 +508,14 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
|
|||||||
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
|
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
|
||||||
};
|
};
|
||||||
static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new();
|
static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new();
|
||||||
|
// SAFETY: `CreateDXGIFactory1` returns a fresh owned `IDXGIFactory1` COM object (refcounted by the
|
||||||
|
// windows-rs wrapper, Released when the local drops); `.ok()?` bails on failure so `factory` is a
|
||||||
|
// valid interface before any use. `EnumAdapters1(i)` hands back the i-th adapter as an owned
|
||||||
|
// `IDXGIAdapter1` (or an error past the last adapter, which ends the loop). `GetDesc1()` returns the
|
||||||
|
// `DXGI_ADAPTER_DESC1` by value (no out-pointer), so reading `desc.Flags`/`desc.VendorId` is plain
|
||||||
|
// field access. Every call only touches COM objects this closure owns; the `OnceLock` runs the
|
||||||
|
// closure once (no data race) and all interfaces are Released as the locals drop. No raw pointer is
|
||||||
|
// dereferenced and nothing is aliased.
|
||||||
*CACHE.get_or_init(|| unsafe {
|
*CACHE.get_or_init(|| unsafe {
|
||||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||||
let mut i = 0u32;
|
let mut i = 0u32;
|
||||||
@@ -539,15 +567,21 @@ pub fn windows_codec_support() -> CodecSupport {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
|
||||||
|
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
|
||||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||||
|
#[path = "encode/windows/ffmpeg_win.rs"]
|
||||||
mod ffmpeg_win;
|
mod ffmpeg_win;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
mod linux;
|
||||||
#[cfg(all(target_os = "windows", feature = "nvenc"))]
|
#[cfg(all(target_os = "windows", feature = "nvenc"))]
|
||||||
|
#[path = "encode/windows/nvenc.rs"]
|
||||||
mod nvenc;
|
mod nvenc;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "encode/windows/sw.rs"]
|
||||||
mod sw;
|
mod sw;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "encode/linux/vaapi.rs"]
|
||||||
mod vaapi;
|
mod vaapi;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+53
@@ -8,6 +8,8 @@
|
|||||||
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
||||||
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
||||||
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{Codec, EncodedFrame, Encoder};
|
use super::{Codec, EncodedFrame, Encoder};
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
@@ -79,6 +81,12 @@ impl CudaHw {
|
|||||||
|
|
||||||
impl Drop for CudaHw {
|
impl Drop for CudaHw {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `CudaHw::new` created
|
||||||
|
// (it bails before returning `Self` if either alloc fails, so a live `CudaHw` always holds
|
||||||
|
// both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`. This
|
||||||
|
// `Drop` runs exactly once and `CudaHw` owns these refs exclusively → no double-free /
|
||||||
|
// use-after-free. Frames are unref'd before the device (the frames ctx internally refs the
|
||||||
|
// device; refcounted, so the order is sound regardless).
|
||||||
unsafe {
|
unsafe {
|
||||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||||
ffi::av_buffer_unref(&mut self.device_ref);
|
ffi::av_buffer_unref(&mut self.device_ref);
|
||||||
@@ -136,6 +144,13 @@ pub struct NvencEncoder {
|
|||||||
|
|
||||||
// `CudaHw` holds raw `AVBufferRef`s; the encoder lives on a single thread. The CPU encoder is
|
// `CudaHw` holds raw `AVBufferRef`s; the encoder lives on a single thread. The CPU encoder is
|
||||||
// already `Send` via ffmpeg-next; assert it for the CUDA fields too.
|
// already `Send` via ffmpeg-next; assert it for the CUDA fields too.
|
||||||
|
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
|
||||||
|
// holding raw `AVBufferRef`s, which are not `Send` by default. The encoder is owned and driven by
|
||||||
|
// exactly ONE thread — the per-session encode thread it is moved to — and is only touched through
|
||||||
|
// `&mut self` methods, so it is never aliased or accessed concurrently. The wrapped libav contexts
|
||||||
|
// (and the shared `CUcontext` the `CudaHw` references) have no thread affinity, so transferring
|
||||||
|
// ownership across threads is sound. This asserts `Send` (transfer) only, extending ffmpeg-next's
|
||||||
|
// existing `Send` to the raw CUDA fields; `Sync` (shared `&`) is deliberately NOT implemented.
|
||||||
unsafe impl Send for NvencEncoder {}
|
unsafe impl Send for NvencEncoder {}
|
||||||
|
|
||||||
impl NvencEncoder {
|
impl NvencEncoder {
|
||||||
@@ -162,6 +177,9 @@ impl NvencEncoder {
|
|||||||
}
|
}
|
||||||
ffmpeg::init().context("ffmpeg init")?;
|
ffmpeg::init().context("ffmpeg init")?;
|
||||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||||
|
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
|
||||||
|
// is a valid level with no pointer args, and libav was just initialized by `ffmpeg::init()`
|
||||||
|
// above — always sound.
|
||||||
unsafe { ffi::av_log_set_level(48) }; // AV_LOG_DEBUG — surface NVENC hw-frame rejects
|
unsafe { ffi::av_log_set_level(48) }; // AV_LOG_DEBUG — surface NVENC hw-frame rejects
|
||||||
}
|
}
|
||||||
let name = codec.nvenc_name();
|
let name = codec.nvenc_name();
|
||||||
@@ -195,6 +213,11 @@ impl NvencEncoder {
|
|||||||
.unwrap_or(1.0);
|
.unwrap_or(1.0);
|
||||||
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
|
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
|
||||||
.clamp(1.0, i32::MAX as f64);
|
.clamp(1.0, i32::MAX as f64);
|
||||||
|
// SAFETY: `video` is the ffmpeg-next encoder builder wrapping a freshly-allocated
|
||||||
|
// `AVCodecContext` that we hold by value and have not opened yet; `video.as_mut_ptr()` returns
|
||||||
|
// that non-null, properly-aligned, exclusively-owned context. Writing the plain `rc_buffer_size`
|
||||||
|
// int field before `open_with` is the supported way to set a field ffmpeg-next exposes no
|
||||||
|
// setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
|
||||||
unsafe {
|
unsafe {
|
||||||
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
|
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
|
||||||
}
|
}
|
||||||
@@ -204,6 +227,9 @@ impl NvencEncoder {
|
|||||||
// "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below)
|
// "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below)
|
||||||
// turns a client recovery request (RFI, via `request_keyframe`) into an IDR on demand.
|
// turns a client recovery request (RFI, via `request_keyframe`) into an IDR on demand.
|
||||||
// This is the Moonlight/Sunshine low-latency model.
|
// This is the Moonlight/Sunshine low-latency model.
|
||||||
|
// SAFETY: same `video` builder as above — a non-null, properly-aligned, sole-owned, not-yet-
|
||||||
|
// opened `AVCodecContext`. We write the plain `gop_size` int field (= -1, infinite GOP) before
|
||||||
|
// `open_with`, which ffmpeg-next has no setter for. No aliasing; synchronous scalar write.
|
||||||
unsafe {
|
unsafe {
|
||||||
(*video.as_mut_ptr()).gop_size = -1;
|
(*video.as_mut_ptr()).gop_size = -1;
|
||||||
}
|
}
|
||||||
@@ -214,6 +240,10 @@ impl NvencEncoder {
|
|||||||
// RGB-input paths leave these unset (NVENC's internal CSC writes its own VUI). Matches the
|
// RGB-input paths leave these unset (NVENC's internal CSC writes its own VUI). Matches the
|
||||||
// Windows NV12 path's BT.709 limited-range signalling.
|
// Windows NV12 path's BT.709 limited-range signalling.
|
||||||
if matches!(format, PixelFormat::Nv12) {
|
if matches!(format, PixelFormat::Nv12) {
|
||||||
|
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
||||||
|
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||||
|
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
|
||||||
|
// Characteristic` variants before `open_with`. Sole owner → no aliasing; synchronous writes.
|
||||||
unsafe {
|
unsafe {
|
||||||
let raw = video.as_mut_ptr();
|
let raw = video.as_mut_ptr();
|
||||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||||
@@ -228,7 +258,17 @@ impl NvencEncoder {
|
|||||||
// *before* open (NVENC derives the device from `hw_frames_ctx`).
|
// *before* open (NVENC derives the device from `hw_frames_ctx`).
|
||||||
let cuda_hw = if cuda {
|
let cuda_hw = if cuda {
|
||||||
let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?;
|
let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?;
|
||||||
|
// SAFETY: `CudaHw::new` (an `unsafe fn`) requires libav initialized (the `ffmpeg::init()`
|
||||||
|
// above ran) and a valid `CUcontext`; `cu_ctx` is the shared importer context from
|
||||||
|
// `zerocopy::cuda::context()?`, non-null on the `Ok` path. `nvenc_pixel` is a valid `Pixel`
|
||||||
|
// and `width`/`height` are the validated positive dims. It returns a RAII `CudaHw` wrapping
|
||||||
|
// (not owning) `cu_ctx` and owning two `AVBufferRef`s freed on drop.
|
||||||
let hw = unsafe { CudaHw::new(cu_ctx, nvenc_pixel, width, height)? };
|
let hw = unsafe { CudaHw::new(cu_ctx, nvenc_pixel, width, height)? };
|
||||||
|
// SAFETY: `raw = video.as_mut_ptr()` is the non-null, sole-owned, not-yet-opened
|
||||||
|
// `AVCodecContext`. We set `pix_fmt = CUDA` and attach NEW refs (`av_buffer_ref`) of
|
||||||
|
// `hw.device_ref`/`hw.frames_ref` — both non-null (`CudaHw::new` guarantees) and from the
|
||||||
|
// live `hw`, which is moved into `NvencEncoder.cuda` next to `enc` and so outlives the
|
||||||
|
// encoder. The context owns its own refs (freed when the context closes). No aliasing.
|
||||||
unsafe {
|
unsafe {
|
||||||
let raw = video.as_mut_ptr();
|
let raw = video.as_mut_ptr();
|
||||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
||||||
@@ -428,6 +468,19 @@ impl NvencEncoder {
|
|||||||
// The device→device copy below uses our shared context directly; make it current on the
|
// The device→device copy below uses our shared context directly; make it current on the
|
||||||
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
|
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
|
||||||
crate::zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
|
crate::zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
|
||||||
|
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
|
||||||
|
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
|
||||||
|
// (`make_current()?`), the precondition for the device-pointer copies below.
|
||||||
|
// * `av_frame_alloc` → `f` (null-checked). `av_hwframe_get_buffer(frames_ref, f, 0)` fills `f`
|
||||||
|
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on
|
||||||
|
// failure we free `f` and bail.
|
||||||
|
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
|
||||||
|
// `data[0]`/`linesize[0]` — in-struct fields of the non-null `f`, valid for the surface dims
|
||||||
|
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf`
|
||||||
|
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface.
|
||||||
|
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and
|
||||||
|
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of
|
||||||
|
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race.
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut f = ffi::av_frame_alloc();
|
let mut f = ffi::av_frame_alloc();
|
||||||
if f.is_null() {
|
if f.is_null() {
|
||||||
+148
-3
@@ -19,6 +19,8 @@
|
|||||||
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
|
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
|
||||||
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
|
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
|
||||||
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
|
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{Codec, EncodedFrame, Encoder};
|
use super::{Codec, EncodedFrame, Encoder};
|
||||||
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
||||||
@@ -133,6 +135,14 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
|||||||
if ffmpeg::init().is_err() {
|
if ffmpeg::init().is_err() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_get_level`/
|
||||||
|
// `av_log_set_level` only read/write libav's global integer log level (no pointer args) and are
|
||||||
|
// always sound to call post-init. `VaapiHw::new` (an `unsafe fn`) builds a VAAPI device + NV12
|
||||||
|
// frames pool from the literal NV12/640x480/pool=2 args and hands back a RAII handle that unrefs
|
||||||
|
// both `AVBufferRef`s on drop. `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/
|
||||||
|
// `hw.frames_ref` — the two non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them
|
||||||
|
// into the encoder; `hw` is a live local for the whole match arm, so the borrows outlive the
|
||||||
|
// synchronous call, and both `hw` and the probe encoder are dropped (RAII) when the arm ends.
|
||||||
unsafe {
|
unsafe {
|
||||||
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
|
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
|
||||||
// ffmpeg's "No VA display found" error for the probe, then restore the level.
|
// ffmpeg's "No VA display found" error for the probe, then restore the level.
|
||||||
@@ -224,6 +234,12 @@ impl VaapiHw {
|
|||||||
|
|
||||||
impl Drop for VaapiHw {
|
impl Drop for VaapiHw {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `VaapiHw::new`
|
||||||
|
// created (it bails before constructing `Self` if either alloc fails, so a live `VaapiHw`
|
||||||
|
// always holds both). `av_buffer_unref` drops one reference and nulls the pointer through the
|
||||||
|
// `&mut`. This `Drop` runs exactly once and `VaapiHw` owns these refs exclusively, so there
|
||||||
|
// is no double-free / use-after-free. Frames are unref'd before the device because the frames
|
||||||
|
// ctx internally holds a ref on the device (refcounted, so the order is sound either way).
|
||||||
unsafe {
|
unsafe {
|
||||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||||
ffi::av_buffer_unref(&mut self.device_ref);
|
ffi::av_buffer_unref(&mut self.device_ref);
|
||||||
@@ -252,7 +268,16 @@ impl CpuInner {
|
|||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let src_pixel = vaapi_sws_src(format)?;
|
let src_pixel = vaapi_sws_src(format)?;
|
||||||
const POOL: c_int = 16;
|
const POOL: c_int = 16;
|
||||||
|
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
|
||||||
|
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
|
||||||
|
// `ffmpeg::init()`. The args are valid: NV12 sw_format, the validated positive `width`/`height`,
|
||||||
|
// pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s on drop.
|
||||||
let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? };
|
let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? };
|
||||||
|
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
|
||||||
|
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
|
||||||
|
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
|
||||||
|
// the encoder holds its own references; `hw` is also moved into the returned `CpuInner` next to
|
||||||
|
// `enc`, keeping the device/frames alive for the encoder's whole lifetime.
|
||||||
let enc = unsafe {
|
let enc = unsafe {
|
||||||
open_vaapi_encoder(
|
open_vaapi_encoder(
|
||||||
codec,
|
codec,
|
||||||
@@ -266,6 +291,12 @@ impl CpuInner {
|
|||||||
};
|
};
|
||||||
// swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale.
|
// swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale.
|
||||||
let src_av = pixel_to_av(src_pixel);
|
let src_av = pixel_to_av(src_pixel);
|
||||||
|
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dimensions and
|
||||||
|
// pixel formats. All four dims are the encoder's positive `width`/`height` cast to `c_int`;
|
||||||
|
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
|
||||||
|
// `src_pixel`), the dst is NV12. The three trailing pointers (srcFilter, dstFilter, param) are
|
||||||
|
// explicitly null = "use defaults", which the API documents as accepted. No Rust memory is
|
||||||
|
// borrowed — only by-value ints/enums — and the returned pointer is null-checked just below.
|
||||||
let sws = unsafe {
|
let sws = unsafe {
|
||||||
ffi::sws_getContext(
|
ffi::sws_getContext(
|
||||||
width as c_int,
|
width as c_int,
|
||||||
@@ -283,10 +314,23 @@ impl CpuInner {
|
|||||||
if sws.is_null() {
|
if sws.is_null() {
|
||||||
bail!("sws_getContext(RGB→NV12) failed");
|
bail!("sws_getContext(RGB→NV12) failed");
|
||||||
}
|
}
|
||||||
|
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
|
||||||
|
// check immediately preceding returned false). `sws_getCoefficients(SWS_CS_ITU709)` returns a
|
||||||
|
// pointer into a libswscale static const coefficient table valid for the whole process, reused
|
||||||
|
// here for both the inverse (src) and forward (dst) matrices. `sws_setColorspaceDetails` only
|
||||||
|
// reads those tables and writes scalar CSC settings into `sws`; the table pointer outlives the
|
||||||
|
// synchronous call and no Rust memory is passed.
|
||||||
unsafe {
|
unsafe {
|
||||||
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||||
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
|
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
|
||||||
}
|
}
|
||||||
|
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
|
||||||
|
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
|
||||||
|
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
|
||||||
|
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
|
||||||
|
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
|
||||||
|
// NV12 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a unique
|
||||||
|
// fresh pointer, so none of these writes alias anything.
|
||||||
let nv12 = unsafe {
|
let nv12 = unsafe {
|
||||||
let f = ffi::av_frame_alloc();
|
let f = ffi::av_frame_alloc();
|
||||||
if f.is_null() {
|
if f.is_null() {
|
||||||
@@ -329,6 +373,18 @@ impl CpuInner {
|
|||||||
let h = self.height as usize;
|
let h = self.height as usize;
|
||||||
let src_row = w * self.src_format.bytes_per_pixel();
|
let src_row = w * self.src_format.bytes_per_pixel();
|
||||||
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
|
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
|
||||||
|
// SAFETY: The `ensure!`s above guarantee `format == self.src_format` and
|
||||||
|
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
|
||||||
|
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
|
||||||
|
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
|
||||||
|
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
|
||||||
|
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
|
||||||
|
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
|
||||||
|
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
|
||||||
|
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
|
||||||
|
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
|
||||||
|
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
|
||||||
|
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
|
||||||
unsafe {
|
unsafe {
|
||||||
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
||||||
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
|
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
|
||||||
@@ -374,6 +430,12 @@ impl CpuInner {
|
|||||||
|
|
||||||
impl Drop for CpuInner {
|
impl Drop for CpuInner {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
|
||||||
|
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
|
||||||
|
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
|
||||||
|
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
|
||||||
|
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
|
||||||
|
// irrelevant to soundness.
|
||||||
unsafe {
|
unsafe {
|
||||||
if !self.nv12.is_null() {
|
if !self.nv12.is_null() {
|
||||||
ffi::av_frame_free(&mut self.nv12);
|
ffi::av_frame_free(&mut self.nv12);
|
||||||
@@ -417,6 +479,31 @@ impl DmabufInner {
|
|||||||
let drm_fourcc = crate::zerocopy::drm_fourcc(format)
|
let drm_fourcc = crate::zerocopy::drm_fourcc(format)
|
||||||
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
|
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
|
||||||
let node = render_node();
|
let node = render_node();
|
||||||
|
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
|
||||||
|
// `ensure_inner` → `DmabufInner::open`). Every raw pointer dereferenced below is either freshly
|
||||||
|
// allocated by the immediately-preceding ffmpeg call and null-checked, or an in-struct field of
|
||||||
|
// such an object:
|
||||||
|
// * `node` is a `CString` (from `render_node`) live for the whole block; its `.as_ptr()` is a
|
||||||
|
// NUL-terminated path read only during `av_hwdevice_ctx_create`.
|
||||||
|
// * `av_hwdevice_ctx_create(&mut drm_device, DRM, …)` / `…_create_derived(&mut vaapi_device,
|
||||||
|
// VAAPI, drm_device, …)`: on `r < 0` the out-param stays null and we bail (the derive path
|
||||||
|
// unrefs `drm_device` first); on success each is a non-null owned `AVBufferRef`.
|
||||||
|
// * `av_hwframe_ctx_alloc(drm_device)` → `drm_frames` (null-checked); `(*drm_frames).data` is
|
||||||
|
// its `AVHWFramesContext` payload, written before `av_hwframe_ctx_init`.
|
||||||
|
// * `avfilter_graph_alloc` → `graph` (null-checked); `avfilter_get_by_name` returns a static
|
||||||
|
// const `AVFilter` (process-lifetime) or null; `avfilter_graph_alloc_filter` allocates each
|
||||||
|
// filter ctx inside `graph`; the four are null-checked together. `inst`/arg strings are
|
||||||
|
// 'static C literals.
|
||||||
|
// * `(*hwmap/scale).hw_device_ctx = av_buffer_ref(vaapi_device)` attaches a NEW ref owned by
|
||||||
|
// the filter (freed by `avfilter_graph_free`); our `vaapi_device` ref is untouched.
|
||||||
|
// * `av_buffersink_get_hw_frames_ctx(sink)` → `nv12_ctx` is a borrowed ref owned by the sink,
|
||||||
|
// valid while `graph` lives (and `graph` is moved into the returned `DmabufInner`).
|
||||||
|
// * `open_vaapi_encoder` borrows `vaapi_device` (our live owned ref) and `nv12_ctx` (sink's
|
||||||
|
// live ref) and `av_buffer_ref`s both into the encoder.
|
||||||
|
// Every early-error path unref's the allocated buffers and frees the graph in the right order
|
||||||
|
// before bailing; on success the four `AVBufferRef`s + `graph` + `src`/`sink` are moved into
|
||||||
|
// `DmabufInner` and freed in its `Drop`. (Two non-UB leaks noted below: `av_buffersrc_*` and
|
||||||
|
// the final `?`.)
|
||||||
unsafe {
|
unsafe {
|
||||||
// DRM device (source dmabuf frames) + a VAAPI device derived from it (same GPU) for
|
// DRM device (source dmabuf frames) + a VAAPI device derived from it (same GPU) for
|
||||||
// hwmap/scale_vaapi/the encoder.
|
// hwmap/scale_vaapi/the encoder.
|
||||||
@@ -509,7 +596,12 @@ impl DmabufInner {
|
|||||||
num: 1,
|
num: 1,
|
||||||
den: fps as c_int,
|
den: fps as c_int,
|
||||||
};
|
};
|
||||||
(*par).hw_frames_ctx = ffi::av_buffer_ref(drm_frames);
|
// Assign `drm_frames` BORROWED (no extra ref): `av_buffersrc_parameters_set` takes its
|
||||||
|
// own ref of `par->hw_frames_ctx` (via av_buffer_replace), and `av_free(par)` frees only
|
||||||
|
// the struct, not the ref. Our single owned `drm_frames` ref is retained, lives in
|
||||||
|
// `DmabufInner`, and is unref'd in `Drop`. Wrapping it in `av_buffer_ref` here would leak
|
||||||
|
// that extra ref every session (the persistent listener would accumulate them).
|
||||||
|
(*par).hw_frames_ctx = drm_frames;
|
||||||
let r = ffi::av_buffersrc_parameters_set(src, par);
|
let r = ffi::av_buffersrc_parameters_set(src, par);
|
||||||
ffi::av_free(par as *mut _);
|
ffi::av_free(par as *mut _);
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
@@ -564,7 +656,12 @@ impl DmabufInner {
|
|||||||
ffi::av_buffer_unref(&mut drm_device);
|
ffi::av_buffer_unref(&mut drm_device);
|
||||||
bail!("filter sink has no VAAPI frames context");
|
bail!("filter sink has no VAAPI frames context");
|
||||||
}
|
}
|
||||||
let enc = open_vaapi_encoder(
|
// On encoder-open failure, free the graph + our owned buffer refs before bailing (matching
|
||||||
|
// every error path above) so a failed session doesn't leak them. `nv12_ctx` is borrowed
|
||||||
|
// from the sink (owned by `graph`), so `avfilter_graph_free` reclaims it — don't unref it
|
||||||
|
// separately. On success the encoder takes its own ref of `vaapi_device`, and `drm_frames`/
|
||||||
|
// `vaapi_device`/`drm_device`/`graph` move into `DmabufInner` (freed in `Drop`).
|
||||||
|
let enc = match open_vaapi_encoder(
|
||||||
codec,
|
codec,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -572,7 +669,16 @@ impl DmabufInner {
|
|||||||
bitrate_bps,
|
bitrate_bps,
|
||||||
vaapi_device,
|
vaapi_device,
|
||||||
nv12_ctx,
|
nv12_ctx,
|
||||||
)?;
|
) {
|
||||||
|
Ok(enc) => enc,
|
||||||
|
Err(e) => {
|
||||||
|
ffi::avfilter_graph_free(&mut graph);
|
||||||
|
ffi::av_buffer_unref(&mut drm_frames);
|
||||||
|
ffi::av_buffer_unref(&mut vaapi_device);
|
||||||
|
ffi::av_buffer_unref(&mut drm_device);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
encoder = codec.vaapi_name(),
|
encoder = codec.vaapi_name(),
|
||||||
@@ -600,6 +706,23 @@ impl DmabufInner {
|
|||||||
dmabuf.fourcc,
|
dmabuf.fourcc,
|
||||||
self.fourcc
|
self.fourcc
|
||||||
);
|
);
|
||||||
|
// SAFETY: The `ensure!` above checked `dmabuf.fourcc == self.fourcc`.
|
||||||
|
// * `std::mem::zeroed::<AVDRMFrameDescriptor>()` is sound: it is a `#[repr(C)]` POD of ints and
|
||||||
|
// nested int-struct arrays (no `NonNull`/refs), for which all-zero is a valid bit pattern;
|
||||||
|
// `Box` puts it on the heap with a unique owner.
|
||||||
|
// * `dmabuf.fd.as_raw_fd()` is the fd of the caller's `&DmabufFrame`, which owns it for the
|
||||||
|
// whole synchronous `submit`; we describe one object/layer/plane from its
|
||||||
|
// fourcc/modifier/offset/stride and pass `object.size = 0` (ffmpeg queries the real size).
|
||||||
|
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
|
||||||
|
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
|
||||||
|
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
|
||||||
|
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
|
||||||
|
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
|
||||||
|
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
|
||||||
|
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
|
||||||
|
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
|
||||||
|
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
|
||||||
|
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
|
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
|
||||||
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
||||||
@@ -626,6 +749,11 @@ impl DmabufInner {
|
|||||||
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
||||||
// which outlives this call — the graph reads the surface before submit returns).
|
// which outlives this call — the graph reads the surface before submit returns).
|
||||||
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
|
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
|
||||||
|
// SAFETY: `data` is exactly the pointer produced by `Box::into_raw(desc)` and passed as
|
||||||
|
// `av_buffer_create`'s first arg, which libav hands back verbatim to this callback. It
|
||||||
|
// is a valid, uniquely-owned `Box<AVDRMFrameDescriptor>` raw pointer; libav invokes the
|
||||||
|
// callback exactly once (when the last buffer ref drops), so `from_raw` + `drop`
|
||||||
|
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
|
||||||
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
|
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
|
||||||
}
|
}
|
||||||
(*drm).buf[0] = ffi::av_buffer_create(
|
(*drm).buf[0] = ffi::av_buffer_create(
|
||||||
@@ -673,6 +801,13 @@ impl DmabufInner {
|
|||||||
|
|
||||||
impl Drop for DmabufInner {
|
impl Drop for DmabufInner {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `graph`/`drm_frames`/`vaapi_device`/`drm_device` are the non-null objects
|
||||||
|
// `DmabufInner::open` built and moved into `self` (open bails before constructing `Self` if any
|
||||||
|
// alloc fails). `avfilter_graph_free` frees the graph (and the per-filter device refs it owns);
|
||||||
|
// each `av_buffer_unref` drops one ref and nulls the pointer via `&mut`. `DmabufInner` owns all
|
||||||
|
// four exclusively and `Drop` runs once → no double-free/use-after-free. The graph is freed
|
||||||
|
// first (it holds refs on the devices), then frames, then the derived VAAPI device, then DRM.
|
||||||
|
// (`self.enc` drops via ffmpeg-next afterward, holding its own refs.)
|
||||||
unsafe {
|
unsafe {
|
||||||
ffi::avfilter_graph_free(&mut self.graph);
|
ffi::avfilter_graph_free(&mut self.graph);
|
||||||
ffi::av_buffer_unref(&mut self.drm_frames);
|
ffi::av_buffer_unref(&mut self.drm_frames);
|
||||||
@@ -703,6 +838,13 @@ pub struct VaapiEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Raw FFI pointers; the encoder lives on a single thread (same contract as `NvencEncoder`).
|
// Raw FFI pointers; the encoder lives on a single thread (same contract as `NvencEncoder`).
|
||||||
|
// SAFETY: `VaapiEncoder`'s `Inner` holds raw FFI pointers (`SwsContext`, `AVFrame`, `AVBufferRef`,
|
||||||
|
// `AVFilterContext`, `AVCodecContext`) that are not `Send` by default. The encoder is owned and
|
||||||
|
// driven by exactly ONE thread — the host's per-session encode thread it is moved (transferred) to —
|
||||||
|
// and is only ever touched through `&mut self` methods, so it is never aliased or accessed
|
||||||
|
// concurrently from two threads. None of the underlying libav/libswscale objects have thread
|
||||||
|
// affinity (they are not thread-local), so transferring ownership across threads is sound. This
|
||||||
|
// asserts `Send` (transfer) only; `Sync` (shared `&`) is deliberately NOT implemented.
|
||||||
unsafe impl Send for VaapiEncoder {}
|
unsafe impl Send for VaapiEncoder {}
|
||||||
|
|
||||||
impl VaapiEncoder {
|
impl VaapiEncoder {
|
||||||
@@ -720,6 +862,9 @@ impl VaapiEncoder {
|
|||||||
}
|
}
|
||||||
ffmpeg::init().context("ffmpeg init")?;
|
ffmpeg::init().context("ffmpeg init")?;
|
||||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||||
|
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
|
||||||
|
// is a valid level and there are no pointer args. libav was just initialized by the
|
||||||
|
// `ffmpeg::init()` above, so the call is always sound.
|
||||||
unsafe { ffi::av_log_set_level(48) };
|
unsafe { ffi::av_log_set_level(48) };
|
||||||
}
|
}
|
||||||
// Validate the codec/format up front so a bad request fails at open, not on the first frame.
|
// Validate the codec/format up front so a bad request fails at open, not on the first frame.
|
||||||
+100
-1
@@ -28,6 +28,8 @@
|
|||||||
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
||||||
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
|
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
|
||||||
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
|
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{Codec, EncodedFrame, Encoder};
|
use super::{Codec, EncodedFrame, Encoder};
|
||||||
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
|
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
|
||||||
@@ -109,7 +111,7 @@ impl WinVendor {
|
|||||||
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated;
|
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated;
|
||||||
/// the default is the robust system-memory readback path.
|
/// the default is the robust system-memory readback path.
|
||||||
fn zerocopy_enabled() -> bool {
|
fn zerocopy_enabled() -> bool {
|
||||||
std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some()
|
crate::config::config().zerocopy
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
||||||
@@ -243,6 +245,12 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
|||||||
if ffmpeg::init().is_err() {
|
if ffmpeg::init().is_err() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// SAFETY: `ffmpeg::init()` succeeded above, so libav's global state is initialised.
|
||||||
|
// `av_log_get_level`/`av_log_set_level` are global scalar getters/setters with no pointer args.
|
||||||
|
// `open_win_encoder` (the `unsafe fn`) is called with null `device_ref`/`frames_ref` (the system
|
||||||
|
// path), so it touches no D3D11/hwcontext — it only allocates and opens a self-contained
|
||||||
|
// libavcodec encoder that is dropped at the end of `.is_ok()`. We restore the prior log level and
|
||||||
|
// no raw pointer escapes the block.
|
||||||
unsafe {
|
unsafe {
|
||||||
// A missing AMF/QSV runtime (wrong-vendor host, GPU-less CI) is an expected probe outcome —
|
// A missing AMF/QSV runtime (wrong-vendor host, GPU-less CI) is an expected probe outcome —
|
||||||
// quiet ffmpeg's open error for the probe, then restore the level.
|
// quiet ffmpeg's open error for the probe, then restore the level.
|
||||||
@@ -337,6 +345,10 @@ impl SystemInner {
|
|||||||
} else {
|
} else {
|
||||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
||||||
};
|
};
|
||||||
|
// SAFETY: calls the `unsafe fn open_win_encoder` with null `device_ref`/`frames_ref`, so the
|
||||||
|
// system path is taken (no hw device/frames context is touched); all other args are scalars.
|
||||||
|
// The returned `encoder::video::Encoder` owns its `AVCodecContext` and frees it on drop; no raw
|
||||||
|
// pointer is aliased.
|
||||||
let enc = unsafe {
|
let enc = unsafe {
|
||||||
open_win_encoder(
|
open_win_encoder(
|
||||||
vendor,
|
vendor,
|
||||||
@@ -352,6 +364,11 @@ impl SystemInner {
|
|||||||
ptr::null_mut(),
|
ptr::null_mut(),
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
|
// SAFETY: `av_frame_alloc` returns a freshly-allocated, uniquely-owned `AVFrame` (null-checked
|
||||||
|
// before any deref); writing `format`/`width`/`height` through `*f` stays inside that
|
||||||
|
// allocation. `av_frame_get_buffer(f, 0)` allocates the backing planes — on failure we
|
||||||
|
// `av_frame_free` the sole owner (no double-free) and bail; on success the raw `f` is moved into
|
||||||
|
// `self.sw_frame` and freed exactly once in `Drop`.
|
||||||
let sw_frame = unsafe {
|
let sw_frame = unsafe {
|
||||||
let f = ffi::av_frame_alloc();
|
let f = ffi::av_frame_alloc();
|
||||||
if f.is_null() {
|
if f.is_null() {
|
||||||
@@ -467,6 +484,18 @@ impl SystemInner {
|
|||||||
} else {
|
} else {
|
||||||
DXGI_FORMAT_NV12
|
DXGI_FORMAT_NV12
|
||||||
};
|
};
|
||||||
|
// SAFETY: `ensure_staging` builds a STAGING texture (CPU_ACCESS_READ) matching `dxgi_fmt` on
|
||||||
|
// `frame.device` — the same `ID3D11Device` that owns `frame.texture` — and caches that device's
|
||||||
|
// immediate context in `self.ctx`. `src`/`dst` are that device's textures of identical NV12/P010
|
||||||
|
// format and dimensions, so `CopyResource` on the single-threaded immediate context is valid.
|
||||||
|
// `Map(.., D3D11_MAP_READ)` succeeds on a staging texture and yields `map.pData` valid for the
|
||||||
|
// whole resource; for NV12/P010 the luma plane is `H` rows at `RowPitch` and the chroma plane
|
||||||
|
// follows at byte offset `RowPitch*H` (`H/2` rows), so `total = pitch*(H+⌈H/2⌉)` is exactly the
|
||||||
|
// mapped extent and `from_raw_parts(base, total)` stays in-bounds. Each `copy_nonoverlapping`
|
||||||
|
// reads a bounds-checked `mapped[..]` sub-slice (`row_bytes ≤ pitch`) and writes `row_bytes ≤
|
||||||
|
// linesize` into the `av_frame_get_buffer`-allocated plane at row `y < H`, so every destination
|
||||||
|
// offset is inside the frame's plane allocation; src and dst never alias. `Unmap` pairs `Map`,
|
||||||
|
// then `send` (the `unsafe fn`) hands `sw_frame` to the encoder.
|
||||||
unsafe {
|
unsafe {
|
||||||
self.ensure_staging(&frame.device, dxgi_fmt)?;
|
self.ensure_staging(&frame.device, dxgi_fmt)?;
|
||||||
let staging = self.staging.clone().context("staging texture")?;
|
let staging = self.staging.clone().context("staging texture")?;
|
||||||
@@ -510,6 +539,14 @@ impl SystemInner {
|
|||||||
if self.ten_bit {
|
if self.ten_bit {
|
||||||
bail!("ffmpeg_win: BGRA readback is 8-bit only (HDR needs the P010 capture path)");
|
bail!("ffmpeg_win: BGRA readback is 8-bit only (HDR needs the P010 capture path)");
|
||||||
}
|
}
|
||||||
|
// SAFETY: `ensure_staging` builds a B8G8R8A8 STAGING texture on `frame.device` and caches that
|
||||||
|
// device's immediate context; `src`/`dst` are that device's textures of matching BGRA format,
|
||||||
|
// so `CopyResource` on the single-threaded context is valid. `Map(READ)` on the staging texture
|
||||||
|
// yields `base` valid for `pitch` × `h` rows. `ensure_sws` lazily builds the BGRA→NV12 context;
|
||||||
|
// `sws_scale` reads `h` rows of `pitch` bytes from `base` (in-bounds — the staging surface is
|
||||||
|
// `≥ pitch*h`) into the `sw_frame` planes addressed by its `data`/`linesize` (allocated for
|
||||||
|
// `width`×`height` NV12). `Unmap` pairs `Map`; the cached `sws` is freed once in `Drop`. The
|
||||||
|
// mapped read region never aliases the owned encoder frame.
|
||||||
unsafe {
|
unsafe {
|
||||||
self.ensure_staging(&frame.device, DXGI_FORMAT_B8G8R8A8_UNORM)?;
|
self.ensure_staging(&frame.device, DXGI_FORMAT_B8G8R8A8_UNORM)?;
|
||||||
let staging = self.staging.clone().context("staging texture")?;
|
let staging = self.staging.clone().context("staging texture")?;
|
||||||
@@ -552,6 +589,13 @@ impl SystemInner {
|
|||||||
/// R10 shader output instead of P010. DXGI `R10G10B10A2_UNORM` (R in the low 10 bits, X2 alpha in
|
/// R10 shader output instead of P010. DXGI `R10G10B10A2_UNORM` (R in the low 10 bits, X2 alpha in
|
||||||
/// the top 2) == FFmpeg `AV_PIX_FMT_X2BGR10LE`. UNTESTED on glass (no AMD/Intel Windows box).
|
/// the top 2) == FFmpeg `AV_PIX_FMT_X2BGR10LE`. UNTESTED on glass (no AMD/Intel Windows box).
|
||||||
fn readback_rgb10(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
|
fn readback_rgb10(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
|
||||||
|
// SAFETY: same shape as `readback_yuv`/`readback_bgra` — `ensure_staging` builds an
|
||||||
|
// R10G10B10A2 STAGING texture on `frame.device` and caches its immediate context; `src`/`dst`
|
||||||
|
// are that device's matching-format textures, so `CopyResource` on the single-threaded context
|
||||||
|
// is valid. `Map(READ)` yields `base` valid for `pitch` × `h` rows. `ensure_sws` builds the
|
||||||
|
// X2BGR10LE→P010 (BT.2020) context; `sws_scale` reads `h` rows of `pitch` bytes from `base`
|
||||||
|
// (in-bounds) into the `sw_frame` P010 planes (`data`/`linesize`, allocated `width`×`height`).
|
||||||
|
// `Unmap` pairs `Map`; `sws` is freed once in `Drop`. No aliasing between read and write.
|
||||||
unsafe {
|
unsafe {
|
||||||
self.ensure_staging(&frame.device, DXGI_FORMAT_R10G10B10A2_UNORM)?;
|
self.ensure_staging(&frame.device, DXGI_FORMAT_R10G10B10A2_UNORM)?;
|
||||||
let staging = self.staging.clone().context("staging texture")?;
|
let staging = self.staging.clone().context("staging texture")?;
|
||||||
@@ -605,6 +649,12 @@ impl SystemInner {
|
|||||||
let h = self.height as usize;
|
let h = self.height as usize;
|
||||||
let src_row = w * format.bytes_per_pixel();
|
let src_row = w * format.bytes_per_pixel();
|
||||||
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
|
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
|
||||||
|
// SAFETY: `ensure_sws` lazily builds the (packed RGB/BGR)→NV12 context for this fixed src/dst
|
||||||
|
// format pair. `src_data[0] = bytes.as_ptr()` with `src_stride[0] = src_row`; the `ensure!`
|
||||||
|
// above guarantees `bytes` holds at least `src_row*h` bytes, so `sws_scale` reads `h` rows of
|
||||||
|
// `src_row` bytes in-bounds and writes the `sw_frame` NV12 planes (`data`/`linesize`, allocated
|
||||||
|
// `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
|
||||||
|
// `sw_frame`. `send` then hands `sw_frame` to the encoder.
|
||||||
unsafe {
|
unsafe {
|
||||||
self.ensure_sws(
|
self.ensure_sws(
|
||||||
pixel_to_av(sws_src(format)?),
|
pixel_to_av(sws_src(format)?),
|
||||||
@@ -667,6 +717,10 @@ impl SystemInner {
|
|||||||
|
|
||||||
impl Drop for SystemInner {
|
impl Drop for SystemInner {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `sw_frame` is the `AVFrame` allocated in `open` (or null) — `av_frame_free` drops it
|
||||||
|
// once and nulls the pointer through the `&mut`; `sws` is the cached `SwsContext` (or null) —
|
||||||
|
// `sws_freeContext` frees it once. This `Drop` runs exactly once and `SystemInner` owns both
|
||||||
|
// exclusively, so there is no double-free or use-after-free.
|
||||||
unsafe {
|
unsafe {
|
||||||
if !self.sw_frame.is_null() {
|
if !self.sw_frame.is_null() {
|
||||||
ffi::av_frame_free(&mut self.sw_frame);
|
ffi::av_frame_free(&mut self.sw_frame);
|
||||||
@@ -745,6 +799,12 @@ impl D3d11Hw {
|
|||||||
|
|
||||||
impl Drop for D3d11Hw {
|
impl Drop for D3d11Hw {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `D3d11Hw::new` created
|
||||||
|
// (it bails before constructing `Self` if either alloc/init fails, so a live `D3d11Hw` always
|
||||||
|
// holds both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`.
|
||||||
|
// This `Drop` runs exactly once and `D3d11Hw` owns these refs exclusively → no double-free /
|
||||||
|
// use-after-free. Frames are unref'd before the device because the frames ctx internally holds
|
||||||
|
// a ref on the device (refcounted, so the order is sound either way).
|
||||||
unsafe {
|
unsafe {
|
||||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||||
ffi::av_buffer_unref(&mut self.device_ref);
|
ffi::av_buffer_unref(&mut self.device_ref);
|
||||||
@@ -800,6 +860,18 @@ impl ZeroCopyInner {
|
|||||||
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
|
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
|
||||||
};
|
};
|
||||||
const POOL: c_int = 8;
|
const POOL: c_int = 8;
|
||||||
|
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
|
||||||
|
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
|
||||||
|
// device_ref/frames_ref pair freed by `D3d11Hw::Drop`; `hw` is a local, so it is dropped (and
|
||||||
|
// both refs freed) on every early `return Err`. For QSV, `av_hwdevice_ctx_create_derived` and
|
||||||
|
// `av_hwframe_ctx_create_derived` fill the null-initialised `qsv_device`/`qsv_frames` out-params
|
||||||
|
// only on success (`r >= 0` checked); on the frames-derive failure we unref the already-created
|
||||||
|
// `qsv_device` before bailing. `open_win_encoder` internally `av_buffer_ref`s the dev/frames
|
||||||
|
// refs it is given (so ownership of `hw`'s and the derived refs stays here), and on its failure
|
||||||
|
// we unref the still-owned derived `qsv_frames`/`qsv_device` (null for AMF → skipped) and return
|
||||||
|
// — `hw` then drops its D3D11 refs. On success the derived refs are moved into `ZeroCopyInner`
|
||||||
|
// (freed in its `Drop`) and the encoder holds its own AddRef'd copies. Every `AVBufferRef` is
|
||||||
|
// unref'd exactly once across all paths — no leak, no double-free.
|
||||||
unsafe {
|
unsafe {
|
||||||
let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?;
|
let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?;
|
||||||
let (pix_fmt, dev_ref, frames_ref, mut qsv_device, mut qsv_frames) = match vendor {
|
let (pix_fmt, dev_ref, frames_ref, mut qsv_device, mut qsv_frames) = match vendor {
|
||||||
@@ -887,6 +959,19 @@ impl ZeroCopyInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
|
fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
|
||||||
|
// SAFETY: `d3d = av_frame_alloc()` is a fresh owned frame (null-checked) and is `av_frame_free`d
|
||||||
|
// exactly once on every path below. `av_hwframe_get_buffer` fills it from the pool — on failure
|
||||||
|
// we free it and bail. `(*d3d).data[0]` is the pool's texture-array and `data[1]` the array
|
||||||
|
// index; `from_raw_borrowed` borrows that `ID3D11Texture2D` WITHOUT taking ownership (no Release
|
||||||
|
// — the frame owns it) and is null-checked. `src` (the captured texture) and `dst` (the pooled
|
||||||
|
// slice) live on the SAME D3D11 device wrapped by `self.hw`, and the caller guarantees
|
||||||
|
// `captured.format == pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, ..,
|
||||||
|
// src, 0, ..)` on the single-threaded immediate context `self.ctx` is a valid same-format GPU
|
||||||
|
// copy. For QSV the mapped `qsv` frame is a fresh owned frame whose `hw_frames_ctx` takes an
|
||||||
|
// `av_buffer_ref` of `self.qsv_frames`; it is `av_frame_free`d (releasing that ref) on both the
|
||||||
|
// map-failure and success paths. `avcodec_send_frame` only internally refs the input frame, so
|
||||||
|
// the `av_frame_free(d3d)`/`av_frame_free(qsv)` afterwards are the sole owning frees — no leak,
|
||||||
|
// no double-free, no use-after-free.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
|
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
|
||||||
let mut d3d = ffi::av_frame_alloc();
|
let mut d3d = ffi::av_frame_alloc();
|
||||||
@@ -959,6 +1044,11 @@ impl ZeroCopyInner {
|
|||||||
|
|
||||||
impl Drop for ZeroCopyInner {
|
impl Drop for ZeroCopyInner {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `qsv_frames`/`qsv_device` are the derived QSV `AVBufferRef`s (or null for AMF); each
|
||||||
|
// is `av_buffer_unref`'d once here (nulling the pointer through the `&mut`) — `ZeroCopyInner`
|
||||||
|
// owns these handles exclusively and this `Drop` runs once, so no double-free. The `enc` and
|
||||||
|
// `hw` fields free the encoder's AddRef'd copies and the D3D11 device/frames refs through their
|
||||||
|
// own `Drop`, so all references stay balanced.
|
||||||
unsafe {
|
unsafe {
|
||||||
if !self.qsv_frames.is_null() {
|
if !self.qsv_frames.is_null() {
|
||||||
ffi::av_buffer_unref(&mut self.qsv_frames);
|
ffi::av_buffer_unref(&mut self.qsv_frames);
|
||||||
@@ -996,6 +1086,13 @@ pub struct FfmpegWinEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI).
|
// Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI).
|
||||||
|
// SAFETY: `FfmpegWinEncoder` owns raw libav pointers (`AVFrame`/`SwsContext`/`AVBufferRef`) and
|
||||||
|
// windows-rs COM handles (`ID3D11Device`/`ID3D11DeviceContext`/textures) that are not auto-`Send`. The
|
||||||
|
// session creates the encoder, drives `submit`/`poll`/`flush`, and drops it all on one dedicated encode
|
||||||
|
// thread; it is never shared by reference across threads, and the D3D11 immediate context is only ever
|
||||||
|
// touched from that thread. The only cross-thread action is the initial move to the encode thread,
|
||||||
|
// after which every interior pointer/COM ref is used single-threaded — the same contract the
|
||||||
|
// NVENC/VAAPI encoders rely on. No interior state is accessed concurrently.
|
||||||
unsafe impl Send for FfmpegWinEncoder {}
|
unsafe impl Send for FfmpegWinEncoder {}
|
||||||
|
|
||||||
impl FfmpegWinEncoder {
|
impl FfmpegWinEncoder {
|
||||||
@@ -1012,6 +1109,8 @@ impl FfmpegWinEncoder {
|
|||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
ffmpeg::init().context("ffmpeg init")?;
|
ffmpeg::init().context("ffmpeg init")?;
|
||||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||||
|
// SAFETY: `ffmpeg::init()` ran on the line above, so libav is initialised; `av_log_set_level`
|
||||||
|
// is a global scalar setter with no pointer arguments.
|
||||||
unsafe { ffi::av_log_set_level(48) };
|
unsafe { ffi::av_log_set_level(48) };
|
||||||
}
|
}
|
||||||
// Make sure the encoder name exists in this libavcodec build up front (clear error vs a
|
// Make sure the encoder name exists in this libavcodec build up front (clear error vs a
|
||||||
+82
-3
@@ -13,7 +13,10 @@
|
|||||||
//! 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, but
|
||||||
//! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback.
|
//! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback.
|
||||||
|
|
||||||
use super::{Codec, EncodedFrame, Encoder};
|
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use super::{Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
@@ -88,7 +91,15 @@ pub struct NvencD3d11Encoder {
|
|||||||
init_device: *mut c_void,
|
init_device: *mut c_void,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw NVENC handle + COM ptrs; confined to the single encode thread (like the Linux encoder).
|
// SAFETY: the `!Send` fields are the raw NVENC session/device handles (`encoder`, `init_device`),
|
||||||
|
// the raw NVENC bitstream/registered/mapped pointers carried in `bitstreams`/`regs`/`pending`, and
|
||||||
|
// the `ID3D11Texture2D` COM refs — none of which may be touched concurrently from two threads. This
|
||||||
|
// encoder is owned by exactly one thread: it is moved onto the host encode thread once at
|
||||||
|
// construction, and every NVENC call and D3D11 access happens only from that thread thereafter
|
||||||
|
// (`submit`/`poll`/`invalidate_ref_frames`/`Drop` all run there, like the Linux encoder). Moving the
|
||||||
|
// handles across that single ownership-transfer boundary is sound because no NVENC/D3D11 call is in
|
||||||
|
// flight during the move and the session and its D3D11 immediate context are never shared (`&`) or
|
||||||
|
// used concurrently — so `Send` introduces no data race on the non-`Send` fields.
|
||||||
unsafe impl Send for NvencD3d11Encoder {}
|
unsafe impl Send for NvencD3d11Encoder {}
|
||||||
|
|
||||||
impl NvencD3d11Encoder {
|
impl NvencD3d11Encoder {
|
||||||
@@ -403,6 +414,17 @@ 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
|
||||||
|
// `nvidia_video_codec_sdk::ENCODE_API` (`nvEncodeAPI`) table, or through this type's own
|
||||||
|
// `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
|
||||||
|
// open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
|
||||||
|
// `try_open_session` just returned (and `best` only when `!best.is_null()`), so it never frees
|
||||||
|
// a dangling or null session. `create_bitstream_buffer` is passed `enc` — the one chosen live
|
||||||
|
// session — and `&mut cb`, a `#[repr(C)] NV_ENC_CREATE_BITSTREAM_BUFFER` whose `version` is set
|
||||||
|
// to `NV_ENC_CREATE_BITSTREAM_BUFFER_VER`; `cb` lives across the synchronous call and its
|
||||||
|
// returned `bitstreamBuffer` is copied into `self.bitstreams` before `cb` drops. No handle
|
||||||
|
// escapes the encode thread.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is
|
// Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is
|
||||||
// gated on what this card supports and an out-of-range mode fails with a clear error
|
// gated on what this card supports and an out-of-range mode fails with a clear error
|
||||||
@@ -589,6 +611,11 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
new = format!("{}x{}", captured.width, captured.height),
|
new = format!("{}x{}", captured.width, captured.height),
|
||||||
"NVENC: capture device/size/HDR changed — re-initializing session"
|
"NVENC: capture device/size/HDR changed — re-initializing session"
|
||||||
);
|
);
|
||||||
|
// SAFETY: `teardown` (an `unsafe fn`) requires the encode thread with no NVENC call in
|
||||||
|
// flight and a session whose cached regs/bitstreams/pending all belong to `self.encoder`.
|
||||||
|
// All hold: this is the synchronous encode thread, `self.inited` so `self.encoder` is the
|
||||||
|
// live session every cached resource was created against, and the previous frame's encode
|
||||||
|
// has already been polled (synchronous submit→poll), so nothing is mid-encode.
|
||||||
unsafe { self.teardown() };
|
unsafe { self.teardown() };
|
||||||
}
|
}
|
||||||
if !self.inited {
|
if !self.inited {
|
||||||
@@ -609,7 +636,14 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
self.bit_depth = 10;
|
self.bit_depth = 10;
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||||
}
|
}
|
||||||
PixelFormat::Nv12 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12,
|
PixelFormat::Nv12 => {
|
||||||
|
// NV12 is 8-bit 4:2:0. Force 8-bit so a transition from a prior P010 (10-bit) session
|
||||||
|
// — or a 10-bit-negotiated client on an SDR display — re-inits at the matching depth.
|
||||||
|
// Unlike ARGB (which NVENC upconverts to Main10), NV12 cannot feed a 10-bit session:
|
||||||
|
// `register_resource` rejects it as InvalidParam (the HDR→SDR-toggle stream drop).
|
||||||
|
self.bit_depth = 8;
|
||||||
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
|
||||||
|
}
|
||||||
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
||||||
};
|
};
|
||||||
let device = frame.device.clone();
|
let device = frame.device.clone();
|
||||||
@@ -618,6 +652,21 @@ 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
|
||||||
|
// 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 =
|
||||||
|
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
|
||||||
|
// `frame.device`, which is the SAME device the session was opened against (any device change
|
||||||
|
// tears down and re-inits above, so `init_device == frame.device.as_raw()` here); the cloned
|
||||||
|
// `ID3D11Texture2D` is kept alive in `regs` so NVENC's registration never outlives the texture.
|
||||||
|
// `mp` (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps that registration and the map is recorded
|
||||||
|
// in `pending` to be unmapped exactly once in `poll`/`teardown`. `pic` (`NV_ENC_PIC_PARAMS`,
|
||||||
|
// version set) points `inputBuffer` at `mp.mappedResource` and `outputBitstream` at the live
|
||||||
|
// pool bitstream `bitstreams[slot]`; the optional SEI scratch (`mastering_sei`/`cll_sei` and the
|
||||||
|
// `sei` Vec whose `as_mut_ptr()` is written into the codec union) are stack locals that outlive
|
||||||
|
// the synchronous `encode_picture`. Every `#[repr(C)]` param is a live local borrowed `&mut`
|
||||||
|
// for the duration of its one synchronous call. (In-place encode without `CopyResource` is
|
||||||
|
// sound because the encode loop is synchronous, as the module docs state.)
|
||||||
unsafe {
|
unsafe {
|
||||||
// Register the capturer's texture with NVENC once (cached by raw pointer), then encode it
|
// Register the capturer's texture with NVENC once (cached by raw pointer), then encode it
|
||||||
// IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the
|
// IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the
|
||||||
@@ -732,6 +781,15 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
self.force_kf = true;
|
self.force_kf = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn caps(&self) -> EncoderCaps {
|
||||||
|
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
|
||||||
|
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||||
|
EncoderCaps {
|
||||||
|
supports_rfi: self.rfi_supported,
|
||||||
|
supports_hdr_metadata: self.hdr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
|
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
|
||||||
// Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every
|
// Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every
|
||||||
// frame; only changes when the source is regraded or HDR toggles.
|
// frame; only changes when the source is regraded or HDR toggles.
|
||||||
@@ -765,6 +823,12 @@ 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.
|
||||||
|
// `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
|
||||||
|
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
|
||||||
|
// DPB; the call passes only that `u64` timestamp (no struct), so there is no struct-size or
|
||||||
|
// 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)
|
||||||
@@ -783,6 +847,16 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
let Some((bs, map, pts_ns)) = self.pending.pop_front() else {
|
let Some((bs, map, pts_ns)) = self.pending.pop_front() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
// 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
|
||||||
|
// pointers from the loaded `ENCODE_API` 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`
|
||||||
|
// targeted; `lock_bitstream` blocks until that encode finishes, so on success
|
||||||
|
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
|
||||||
|
// NVENC-owned, CPU-readable output valid until `unlock_bitstream`. The `from_raw_parts` slice is
|
||||||
|
// only read (copied via `to_vec()`) BEFORE `unlock_bitstream(bs)` — lock and unlock pair on the
|
||||||
|
// same buffer — so it never outlives the lock. `map` (the input resource paired with `bs` in
|
||||||
|
// `pending`) is unmapped here, after the encode completed, exactly once.
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||||
@@ -822,6 +896,11 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
|
|
||||||
impl Drop for NvencD3d11Encoder {
|
impl Drop for NvencD3d11Encoder {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `teardown` (an `unsafe fn`) needs the owning thread with no NVENC call in flight and
|
||||||
|
// a session whose cached resources all belong to `self.encoder`. At Drop this encoder is owned
|
||||||
|
// exclusively (no other reference can exist), runs on the encode thread it was confined to, and
|
||||||
|
// `teardown` early-returns when `self.encoder` is null; otherwise every cached reg/bitstream/
|
||||||
|
// pending was created against that live session. It runs exactly once (here).
|
||||||
unsafe { self.teardown() };
|
unsafe { self.teardown() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
@@ -2,6 +2,8 @@
|
|||||||
//! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference,
|
//! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference,
|
||||||
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR, BT.709 limited range.
|
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR, BT.709 limited range.
|
||||||
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
|
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{EncodedFrame, Encoder};
|
use super::{EncodedFrame, Encoder};
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
@@ -30,6 +32,12 @@ pub struct OpenH264Encoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread.
|
// openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread.
|
||||||
|
// SAFETY: `OpenH264Encoder` wraps `Oh264` (openh264's `Encoder`), which holds a raw C handle to the
|
||||||
|
// openh264 `ISVCEncoder` and is not auto-`Send`; the other fields (`YUVBuffer`, `Vec`, scalars,
|
||||||
|
// `Option<EncodedFrame>`) are plain owned data. The session creates the encoder, calls
|
||||||
|
// `submit`/`poll`/`flush`, and drops it all on one dedicated encode thread, never sharing it by
|
||||||
|
// reference across threads, so the C handle is only ever touched from a single thread. Moving the
|
||||||
|
// whole value to that thread is therefore sound — there is no concurrent access to the handle.
|
||||||
unsafe impl Send for OpenH264Encoder {}
|
unsafe impl Send for OpenH264Encoder {}
|
||||||
|
|
||||||
impl OpenH264Encoder {
|
impl OpenH264Encoder {
|
||||||
@@ -17,6 +17,9 @@
|
|||||||
//! data packets are consumed immediately and missing parity only costs loss recovery — so
|
//! data packets are consumed immediately and missing parity only costs loss recovery — so
|
||||||
//! the validated stereo path stays byte-identical (data packets only, exactly as before).
|
//! the validated stereo path stays byte-identical (data packets only, exactly as before).
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows", test))]
|
#[cfg(any(target_os = "linux", target_os = "windows", test))]
|
||||||
use crate::audio::SAMPLE_RATE;
|
use crate::audio::SAMPLE_RATE;
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
@@ -409,7 +412,10 @@ struct MsEncoder {
|
|||||||
st: std::ptr::NonNull<audiopus_sys::OpusMSEncoder>,
|
st: std::ptr::NonNull<audiopus_sys::OpusMSEncoder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// The raw encoder state has no thread affinity; the session owns it on one thread at a time.
|
// SAFETY: `MsEncoder` owns a unique `OpusMSEncoder` via `NonNull` (it is neither `Clone` nor
|
||||||
|
// `Sync`, so the pointer is never aliased). libopus's multistream encoder state is a self-contained
|
||||||
|
// heap allocation with no thread-local or thread-affine state, so moving ownership to another thread
|
||||||
|
// is sound; every method takes `&mut self`, keeping access single-threaded at any instant.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
unsafe impl Send for MsEncoder {}
|
unsafe impl Send for MsEncoder {}
|
||||||
|
|
||||||
@@ -418,6 +424,13 @@ impl MsEncoder {
|
|||||||
fn new(layout: &OpusLayout) -> Result<MsEncoder> {
|
fn new(layout: &OpusLayout) -> Result<MsEncoder> {
|
||||||
use std::os::raw::c_int;
|
use std::os::raw::c_int;
|
||||||
let mut err: c_int = 0;
|
let mut err: c_int = 0;
|
||||||
|
// SAFETY: every scalar arg is a valid libopus input (sample rate, channel/stream/coupled
|
||||||
|
// counts, the RESTRICTED_LOWDELAY application constant). `layout.mapping.as_ptr()` addresses
|
||||||
|
// a 'static slice of exactly `layout.channels` bytes (every `OpusLayout` constant upholds
|
||||||
|
// that), which is the element count `opus_multistream_encoder_create` reads through it, and
|
||||||
|
// `&mut err` is a live local the call writes its status into. libopus copies the mapping into
|
||||||
|
// its own allocation, so the pointer need only be valid for the call; the returned pointer is
|
||||||
|
// null/`OPUS_OK`-checked below before any use.
|
||||||
let st = unsafe {
|
let st = unsafe {
|
||||||
audiopus_sys::opus_multistream_encoder_create(
|
audiopus_sys::opus_multistream_encoder_create(
|
||||||
SAMPLE_RATE as i32,
|
SAMPLE_RATE as i32,
|
||||||
@@ -432,6 +445,11 @@ impl MsEncoder {
|
|||||||
let st = std::ptr::NonNull::new(st)
|
let st = std::ptr::NonNull::new(st)
|
||||||
.filter(|_| err == audiopus_sys::OPUS_OK)
|
.filter(|_| err == audiopus_sys::OPUS_OK)
|
||||||
.ok_or_else(|| anyhow::anyhow!("opus_multistream_encoder_create failed ({err})"))?;
|
.ok_or_else(|| anyhow::anyhow!("opus_multistream_encoder_create failed ({err})"))?;
|
||||||
|
// SAFETY: `st` is the non-null encoder `opus_multistream_encoder_create` just returned, owned
|
||||||
|
// exclusively here. Each `opus_multistream_encoder_ctl` call passes a valid request constant
|
||||||
|
// with the single by-value `c_int` argument that request's variadic ABI expects
|
||||||
|
// (`OPUS_SET_BITRATE_REQUEST` → bitrate, `OPUS_SET_VBR_REQUEST` → 0). No pointer escapes the
|
||||||
|
// call and the encoder outlives it.
|
||||||
unsafe {
|
unsafe {
|
||||||
audiopus_sys::opus_multistream_encoder_ctl(
|
audiopus_sys::opus_multistream_encoder_ctl(
|
||||||
st.as_ptr(),
|
st.as_ptr(),
|
||||||
@@ -453,6 +471,13 @@ impl MsEncoder {
|
|||||||
samples_per_channel: usize,
|
samples_per_channel: usize,
|
||||||
out: &mut [u8],
|
out: &mut [u8],
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
|
// SAFETY: `self.st` is the live encoder from `new`. libopus reads `samples_per_channel *
|
||||||
|
// channels` f32s through `frame.as_ptr()`; every caller passes a `frame` of exactly that
|
||||||
|
// length together with the matching `samples_per_channel` (`audio_body`'s `frame_len =
|
||||||
|
// samples_per_channel * layout.channels`; the round-trip tests size identically), so the read
|
||||||
|
// stays in bounds. `out.as_mut_ptr()` is written for at most `out.len()` bytes, which is
|
||||||
|
// passed as the capacity bound. Both buffers are live locals outliving this synchronous call;
|
||||||
|
// the return value is range-checked before being used as a length.
|
||||||
let n = unsafe {
|
let n = unsafe {
|
||||||
audiopus_sys::opus_multistream_encode_float(
|
audiopus_sys::opus_multistream_encode_float(
|
||||||
self.st.as_ptr(),
|
self.st.as_ptr(),
|
||||||
@@ -470,6 +495,9 @@ impl MsEncoder {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
impl Drop for MsEncoder {
|
impl Drop for MsEncoder {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.st` is the encoder `opus_multistream_encoder_create` returned; this
|
||||||
|
// `MsEncoder` owns it uniquely and `drop` runs exactly once, so the destroy frees it once
|
||||||
|
// with no subsequent use.
|
||||||
unsafe { audiopus_sys::opus_multistream_encoder_destroy(self.st.as_ptr()) }
|
unsafe { audiopus_sys::opus_multistream_encoder_destroy(self.st.as_ptr()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,6 +789,10 @@ mod tests {
|
|||||||
let client_mapping = client_swap(&digits[3..]);
|
let client_mapping = client_swap(&digits[3..]);
|
||||||
|
|
||||||
let mut err = 0i32;
|
let mut err = 0i32;
|
||||||
|
// SAFETY: scalar args are valid libopus inputs. `client_mapping.as_ptr()` addresses a
|
||||||
|
// `Vec<u8>` of exactly `ch` entries (derived from the advertised surround-params), which is
|
||||||
|
// the element count the decoder reads through it, and `&mut err` is a live local the call
|
||||||
|
// writes. The returned pointer is `OPUS_OK`/non-null-checked immediately below before use.
|
||||||
let dec = unsafe {
|
let dec = unsafe {
|
||||||
audiopus_sys::opus_multistream_decoder_create(
|
audiopus_sys::opus_multistream_decoder_create(
|
||||||
SAMPLE_RATE as i32,
|
SAMPLE_RATE as i32,
|
||||||
@@ -789,6 +821,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let n = enc.encode_float(&frame, samples, &mut out).unwrap();
|
let n = enc.encode_float(&frame, samples, &mut out).unwrap();
|
||||||
assert!(n > 0);
|
assert!(n > 0);
|
||||||
|
// SAFETY: `dec` is the non-null decoder asserted above. `out.as_ptr()` is read for
|
||||||
|
// the `n` encoded bytes just produced by `encode_float`; `decoded.as_mut_ptr()` is
|
||||||
|
// written for up to `samples * ch` f32s and `decoded` is exactly that long; `samples`
|
||||||
|
// is the per-channel frame size. All buffers are live locals outliving the call; the
|
||||||
|
// return is checked to equal `samples`.
|
||||||
let got = unsafe {
|
let got = unsafe {
|
||||||
audiopus_sys::opus_multistream_decode_float(
|
audiopus_sys::opus_multistream_decode_float(
|
||||||
dec,
|
dec,
|
||||||
@@ -817,6 +854,8 @@ mod tests {
|
|||||||
(energies: {energy:?})"
|
(energies: {energy:?})"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// SAFETY: `dec` is the decoder `opus_multistream_decoder_create` returned; the test owns it
|
||||||
|
// and destroys it exactly once here, after the final decode — no later use, no double free.
|
||||||
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
|
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,6 +892,9 @@ mod tests {
|
|||||||
let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect();
|
let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect();
|
||||||
let client_mapping = client_swap(&digits[3..]);
|
let client_mapping = client_swap(&digits[3..]);
|
||||||
let mut err = 0i32;
|
let mut err = 0i32;
|
||||||
|
// SAFETY: scalar args are valid; `client_mapping.as_ptr()` addresses a 6-entry `Vec<u8>`
|
||||||
|
// (matches the 6-channel layout the decoder reads through it), alive past the call, and
|
||||||
|
// `&mut err` is a live local. The pointer is `OPUS_OK`-checked before use.
|
||||||
let dec = unsafe {
|
let dec = unsafe {
|
||||||
audiopus_sys::opus_multistream_decoder_create(
|
audiopus_sys::opus_multistream_decoder_create(
|
||||||
48000,
|
48000,
|
||||||
@@ -865,6 +907,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(err, audiopus_sys::OPUS_OK);
|
assert_eq!(err, audiopus_sys::OPUS_OK);
|
||||||
let mut pcm = vec![0f32; 240 * 6];
|
let mut pcm = vec![0f32; 240 * 6];
|
||||||
|
// SAFETY: `dec` is the non-null decoder from create. `out.as_ptr()` is read for the CBR
|
||||||
|
// packet length passed in (`*sizes.first()`, a real encoded packet size in `out`);
|
||||||
|
// `pcm.as_mut_ptr()` is written for up to `240 * 6` f32s and `pcm` is exactly that long;
|
||||||
|
// `240` is the per-channel frame size. All buffers are live locals outliving the call.
|
||||||
let got = unsafe {
|
let got = unsafe {
|
||||||
audiopus_sys::opus_multistream_decode_float(
|
audiopus_sys::opus_multistream_decode_float(
|
||||||
dec,
|
dec,
|
||||||
@@ -875,6 +921,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
// SAFETY: `dec` is owned by the test; destroyed exactly once here after the final decode.
|
||||||
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
|
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
|
||||||
assert_eq!(got, 240);
|
assert_eq!(got, 240);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Pairing crypto primitives (control plane only — distinct from `punktfunk_core`'s AES-GCM
|
//! Pairing crypto primitives (control plane only — distinct from `punktfunk_core`'s AES-GCM
|
||||||
//! data-plane sealing). GameStream pairing uses: AES-128-**ECB** with **no padding**,
|
//! data-plane sealing). GameStream pairing uses: AES-128-**ECB** with **no padding**,
|
||||||
//! SHA-256 (host appversion major ≥ 7), and RSA-PKCS1v15-SHA256 signatures. See the
|
//! SHA-256 (host appversion major ≥ 7), and RSA-PKCS1v15-SHA256 signatures. See the
|
||||||
//! `serverinfo + pairing` section of `docs/research/gamestream-protocol-research.json`.
|
//! `serverinfo + pairing` section of `design/research/gamestream-protocol-research.json`.
|
||||||
|
|
||||||
use aes::cipher::generic_array::GenericArray;
|
use aes::cipher::generic_array::GenericArray;
|
||||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
|
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! GameStream (P1) control plane — what a stock Moonlight/Artemis client talks to around
|
//! GameStream (P1) control plane — what a stock Moonlight/Artemis client talks to around
|
||||||
//! the media streams: mDNS discovery, the nvhttp serverinfo + pairing HTTP(S) API, RTSP,
|
//! the media streams: mDNS discovery, the nvhttp serverinfo + pairing HTTP(S) API, RTSP,
|
||||||
//! and the ENet control stream. `tokio`/`axum` live here (control plane, I/O-bound — never
|
//! and the ENet control stream. `tokio`/`axum` live here (control plane, I/O-bound — never
|
||||||
//! the per-frame hot path; that is `punktfunk_core`'s P1 wire codec). See `docs/gamestream-host-plan.md`.
|
//! the per-frame hot path; that is `punktfunk_core`'s P1 wire codec). See `design/gamestream-host-plan.md`.
|
||||||
//!
|
//!
|
||||||
//! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and
|
//! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and
|
||||||
//! the media streams follow (see the GameStream host task list / plan).
|
//! the media streams follow (see the GameStream host task list / plan).
|
||||||
@@ -125,12 +125,21 @@ pub struct AppState {
|
|||||||
/// (avoids a PipeWire stream setup per reconnect); drained on reuse so no stale audio is
|
/// (avoids a PipeWire stream setup per reconnect); drained on reuse so no stale audio is
|
||||||
/// sent, dropped + reopened when a session negotiates a different channel count.
|
/// sent, dropped + reopened when a session negotiates a different channel count.
|
||||||
pub audio_cap: std::sync::Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>,
|
pub audio_cap: std::sync::Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>,
|
||||||
|
/// Shared streaming-stats recorder (web-console capture/graph). The GameStream encode loop
|
||||||
|
/// reads `is_armed()` per frame and emits samples; the same `Arc` is shared with the mgmt API
|
||||||
|
/// and the native punktfunk/1 loops so one capture spans whichever path is streaming.
|
||||||
|
pub stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
|
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
|
||||||
/// disk (pairings persist across restarts).
|
/// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
|
||||||
pub fn new(host: Host, identity: cert::ServerIdentity) -> AppState {
|
/// mgmt API and the streaming loops.
|
||||||
|
pub fn new(
|
||||||
|
host: Host,
|
||||||
|
identity: cert::ServerIdentity,
|
||||||
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
|
) -> AppState {
|
||||||
AppState {
|
AppState {
|
||||||
host,
|
host,
|
||||||
identity,
|
identity,
|
||||||
@@ -145,6 +154,7 @@ impl AppState {
|
|||||||
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||||
video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||||
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||||
|
stats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +176,10 @@ pub fn serve(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let host = Host::detect()?;
|
let host = Host::detect()?;
|
||||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||||
let state = Arc::new(AppState::new(host, identity));
|
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
|
||||||
|
// (via `AppState`), and the native punktfunk/1 loops (passed to `punktfunk1::serve`).
|
||||||
|
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
|
||||||
|
let state = Arc::new(AppState::new(host, identity, stats.clone()));
|
||||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||||
// and the management API) always exists.
|
// and the management API) always exists.
|
||||||
let np = Arc::new(
|
let np = Arc::new(
|
||||||
@@ -206,8 +219,8 @@ pub fn serve(
|
|||||||
);
|
);
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
nvhttp::run(state.clone()),
|
nvhttp::run(state.clone()),
|
||||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone())),
|
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
|
||||||
crate::punktfunk1::serve(native_opts, np),
|
crate::punktfunk1::serve(native_opts, np, stats.clone()),
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
|
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
|
||||||
@@ -217,8 +230,8 @@ pub fn serve(
|
|||||||
(GameStream OFF — pass --gamestream for stock-Moonlight compat)"
|
(GameStream OFF — pass --gamestream for stock-Moonlight compat)"
|
||||||
);
|
);
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone())),
|
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
|
||||||
crate::punktfunk1::serve(native_opts, np),
|
crate::punktfunk1::serve(native_opts, np, stats.clone()),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -291,7 +291,10 @@ mod tests {
|
|||||||
https_port: HTTPS_PORT,
|
https_port: HTTPS_PORT,
|
||||||
};
|
};
|
||||||
let identity = super::super::cert::ServerIdentity::ephemeral().expect("ephemeral identity");
|
let identity = super::super::cert::ServerIdentity::ephemeral().expect("ephemeral identity");
|
||||||
Arc::new(AppState::new(host, identity))
|
let stats = crate::stats_recorder::StatsRecorder::new(
|
||||||
|
std::env::temp_dir().join(format!("pf-nvhttp-stats-{}", std::process::id())),
|
||||||
|
);
|
||||||
|
Arc::new(AppState::new(host, identity, stats))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fp_of(der: &[u8]) -> String {
|
fn fp_of(der: &[u8]) -> String {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! The 4-phase GameStream pairing state machine (over HTTP), keyed by `uniqueid`. Proves
|
//! The 4-phase GameStream pairing state machine (over HTTP), keyed by `uniqueid`. Proves
|
||||||
//! both sides know the PIN (via the SHA-256(salt||pin) AES-ECB key) and own their certs
|
//! both sides know the PIN (via the SHA-256(salt||pin) AES-ECB key) and own their certs
|
||||||
//! (RSA signatures), then pins the client cert. The final `pairchallenge` happens over
|
//! (RSA signatures), then pins the client cert. The final `pairchallenge` happens over
|
||||||
//! HTTPS (handled in `nvhttp`). Byte-exact spec: `docs/research/…-research.json`.
|
//! HTTPS (handled in `nvhttp`). Byte-exact spec: `design/research/…-research.json`.
|
||||||
|
|
||||||
use super::cert::ServerIdentity;
|
use super::cert::ServerIdentity;
|
||||||
use super::crypto;
|
use super::crypto;
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ fn handle_request(req: &Request, state: &AppState) -> String {
|
|||||||
state.force_idr.clone(),
|
state.force_idr.clone(),
|
||||||
state.rfi_range.clone(),
|
state.rfi_range.clone(),
|
||||||
state.video_cap.clone(),
|
state.video_cap.clone(),
|
||||||
|
state.stats.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Some(_) => tracing::info!("RTSP PLAY — stream already running"),
|
Some(_) => tracing::info!("RTSP PLAY — stream already running"),
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or
|
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or
|
||||||
//! a synthetic test pattern (default). Runs on its own native thread.
|
//! a synthetic test pattern (default). Runs on its own native thread.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::video::{FrameType, VideoPacketizer};
|
use super::video::{FrameType, VideoPacketizer};
|
||||||
use super::VIDEO_PORT;
|
use super::VIDEO_PORT;
|
||||||
use crate::capture::{self, Capturer, FastSyntheticCapturer};
|
use crate::capture::{self, Capturer, FastSyntheticCapturer};
|
||||||
@@ -45,6 +48,7 @@ pub fn start(
|
|||||||
force_idr: Arc<AtomicBool>,
|
force_idr: Arc<AtomicBool>,
|
||||||
rfi_range: RfiSlot,
|
rfi_range: RfiSlot,
|
||||||
video_cap: CapturerSlot,
|
video_cap: CapturerSlot,
|
||||||
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
) {
|
) {
|
||||||
let _ = std::thread::Builder::new()
|
let _ = std::thread::Builder::new()
|
||||||
.name("punktfunk-video".into())
|
.name("punktfunk-video".into())
|
||||||
@@ -57,6 +61,7 @@ pub fn start(
|
|||||||
&force_idr,
|
&force_idr,
|
||||||
&rfi_range,
|
&rfi_range,
|
||||||
&video_cap,
|
&video_cap,
|
||||||
|
&stats,
|
||||||
) {
|
) {
|
||||||
tracing::error!(error = %format!("{e:#}"), "video stream failed");
|
tracing::error!(error = %format!("{e:#}"), "video stream failed");
|
||||||
}
|
}
|
||||||
@@ -65,6 +70,7 @@ pub fn start(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn run(
|
fn run(
|
||||||
cfg: StreamConfig,
|
cfg: StreamConfig,
|
||||||
app: Option<&super::apps::AppEntry>,
|
app: Option<&super::apps::AppEntry>,
|
||||||
@@ -72,6 +78,9 @@ fn run(
|
|||||||
force_idr: &AtomicBool,
|
force_idr: &AtomicBool,
|
||||||
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
||||||
video_cap: &std::sync::Mutex<Option<Box<dyn Capturer>>>,
|
video_cap: &std::sync::Mutex<Option<Box<dyn Capturer>>>,
|
||||||
|
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
||||||
|
// encode loop); per-frame sample emission is wired by a later pass.
|
||||||
|
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
|
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
|
||||||
crate::session_tuning::on_hot_thread();
|
crate::session_tuning::on_hot_thread();
|
||||||
@@ -97,12 +106,14 @@ fn run(
|
|||||||
sock.connect(client)
|
sock.connect(client)
|
||||||
.context("connect client video endpoint")?;
|
.context("connect client video endpoint")?;
|
||||||
tracing::info!(%client, "video: client endpoint learned");
|
tracing::info!(%client, "video: client endpoint learned");
|
||||||
|
// Short label for web-console stats captures: the client's peer IP.
|
||||||
|
let client_label = client.ip().to_string();
|
||||||
|
|
||||||
// Native client-resolution source: create a compositor virtual output sized to the client's
|
// Native client-resolution source: create a compositor virtual output sized to the client's
|
||||||
// request and capture it (no scaling). Self-contained — deliberately NOT pooled in
|
// request and capture it (no scaling). Self-contained — deliberately NOT pooled in
|
||||||
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
||||||
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
||||||
if std::env::var("PUNKTFUNK_VIDEO_SOURCE").as_deref() == Ok("virtual") {
|
if crate::config::config().video_source.as_deref() == Some("virtual") {
|
||||||
// The launched app picks the compositor (e.g. gamescope for game entries) and the
|
// The launched app picks the compositor (e.g. gamescope for game entries) and the
|
||||||
// nested command.
|
// nested command.
|
||||||
let compositor = app
|
let compositor = app
|
||||||
@@ -127,10 +138,49 @@ fn run(
|
|||||||
refresh_hz: cfg.fps,
|
refresh_hz: cfg.fps,
|
||||||
})
|
})
|
||||||
.context("create virtual output at client resolution")?;
|
.context("create virtual output at client resolution")?;
|
||||||
let mut capturer =
|
// `want_hdr=false`: the IDD-push backend (opt-in PUNKTFUNK_IDD_PUSH) has no monitor-HDR
|
||||||
capture::capture_virtual_output(vout).context("capture virtual output")?;
|
// auto-detection — it converts its always-FP16 ring per this flag — and GameStream HDR is not
|
||||||
|
// negotiated into StreamConfig here, so an IDD-push GameStream session streams SDR even on an
|
||||||
|
// HDR desktop. (The default WGC backend DOES auto-detect HDR from the output colorspace, but
|
||||||
|
// IDD-push bypasses WGC.) Acceptable for the experimental IDD-push A/B path; HDR over IDD-push
|
||||||
|
// is wired only for punktfunk/1 (want_hdr = negotiated bit_depth >= 10). TODO: derive want_hdr
|
||||||
|
// from a GameStream HDR flag once StreamConfig carries one.
|
||||||
|
let mut capturer = capture::capture_virtual_output(
|
||||||
|
vout,
|
||||||
|
capture::OutputFormat::resolve(false),
|
||||||
|
crate::session_plan::CaptureBackend::resolve(),
|
||||||
|
)
|
||||||
|
.context("capture virtual output")?;
|
||||||
capturer.set_active(true);
|
capturer.set_active(true);
|
||||||
return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range);
|
// Launch the app's command now that capture is live, for the backends that DON'T nest it via
|
||||||
|
// set_launch_command above: Windows (no gamescope) and Linux kwin/mutter/wlroots (which stream
|
||||||
|
// the existing desktop, so the app must be spawned into the session to land on the streamed
|
||||||
|
// output). Linux gamescope already nested it via set_launch_command, so skip it there.
|
||||||
|
#[cfg(windows)]
|
||||||
|
let launch_here = true;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
|
||||||
|
#[cfg(any(windows, target_os = "linux"))]
|
||||||
|
if launch_here {
|
||||||
|
if let Some(cmd) = app
|
||||||
|
.and_then(|a| a.cmd.as_deref())
|
||||||
|
.filter(|c| !c.trim().is_empty())
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::library::launch_gamestream_command(cmd) {
|
||||||
|
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stream_body(
|
||||||
|
&mut *capturer,
|
||||||
|
&sock,
|
||||||
|
cfg,
|
||||||
|
running,
|
||||||
|
force_idr,
|
||||||
|
rfi_range,
|
||||||
|
stats,
|
||||||
|
&client_label,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse the persistent capturer (one screencast session → clean reconnect); create it on
|
// Reuse the persistent capturer (one screencast session → clean reconnect); create it on
|
||||||
@@ -140,7 +190,7 @@ fn run(
|
|||||||
tracing::info!("video source: reusing capturer");
|
tracing::info!("video source: reusing capturer");
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
None if std::env::var("PUNKTFUNK_VIDEO_SOURCE").is_ok_and(|v| v == "portal") => {
|
None if crate::config::config().video_source.as_deref() == Some("portal") => {
|
||||||
tracing::info!("video source: portal desktop capture");
|
tracing::info!("video source: portal desktop capture");
|
||||||
capture::open_portal_monitor().context("open portal capturer")?
|
capture::open_portal_monitor().context("open portal capturer")?
|
||||||
}
|
}
|
||||||
@@ -150,7 +200,16 @@ fn run(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
capturer.set_active(true);
|
capturer.set_active(true);
|
||||||
let result = stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range);
|
let result = stream_body(
|
||||||
|
&mut *capturer,
|
||||||
|
&sock,
|
||||||
|
cfg,
|
||||||
|
running,
|
||||||
|
force_idr,
|
||||||
|
rfi_range,
|
||||||
|
stats,
|
||||||
|
&client_label,
|
||||||
|
);
|
||||||
capturer.set_active(false);
|
capturer.set_active(false);
|
||||||
*video_cap.lock().unwrap() = Some(capturer);
|
*video_cap.lock().unwrap() = Some(capturer);
|
||||||
result
|
result
|
||||||
@@ -177,6 +236,10 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
|
|||||||
let mut hdrs: Vec<libc::mmsghdr> = iovs
|
let mut hdrs: Vec<libc::mmsghdr> = iovs
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.map(|iov| {
|
.map(|iov| {
|
||||||
|
// SAFETY: `libc::mmsghdr` is a plain `#[repr(C)]` struct of integers and raw
|
||||||
|
// pointers, for which an all-zero bit pattern is valid (null pointers / zero
|
||||||
|
// lengths); the fields we rely on (`msg_iov`, `msg_iovlen`) are overwritten on the
|
||||||
|
// next two lines before the struct is handed to the kernel.
|
||||||
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
|
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
|
||||||
h.msg_hdr.msg_iov = iov;
|
h.msg_hdr.msg_iov = iov;
|
||||||
h.msg_hdr.msg_iovlen = 1;
|
h.msg_hdr.msg_iovlen = 1;
|
||||||
@@ -185,6 +248,13 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
|
|||||||
.collect();
|
.collect();
|
||||||
let mut off = 0usize;
|
let mut off = 0usize;
|
||||||
while off < hdrs.len() {
|
while off < hdrs.len() {
|
||||||
|
// SAFETY: `fd` is `sock`'s live raw fd (`sock` outlives the call). `hdrs[off..]
|
||||||
|
// .as_mut_ptr()` is a live slice of `(hdrs.len() - off)` `mmsghdr`s — exactly the count
|
||||||
|
// passed — into which the kernel writes each `msg_len`. Each header's `msg_iov` points
|
||||||
|
// into `iovs` (a local that outlives this call, with `msg_iovlen == 1` matching its one
|
||||||
|
// entry) and each `iovec.iov_base` points into the `chunk` packet buffers (the caller's
|
||||||
|
// `pkts`, alive for the call); the kernel only reads those payloads. Flags 0; the return
|
||||||
|
// is error-/progress-checked before advancing `off`.
|
||||||
let n = unsafe {
|
let n = unsafe {
|
||||||
libc::sendmmsg(fd, hdrs[off..].as_mut_ptr(), (hdrs.len() - off) as u32, 0)
|
libc::sendmmsg(fd, hdrs[off..].as_mut_ptr(), (hdrs.len() - off) as u32, 0)
|
||||||
};
|
};
|
||||||
@@ -282,8 +352,20 @@ fn spawn_sender(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Percentile of a slice (sorts it in place first). `q` in `0.0..=1.0`. Used for the web-console
|
||||||
|
/// stats sample's per-stage p50/p99.
|
||||||
|
fn percentile(v: &mut [u32], q: f64) -> u32 {
|
||||||
|
if v.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
v.sort_unstable();
|
||||||
|
let i = ((v.len() as f64 * q) as usize).min(v.len() - 1);
|
||||||
|
v[i]
|
||||||
|
}
|
||||||
|
|
||||||
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
|
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
|
||||||
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
|
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn stream_body(
|
fn stream_body(
|
||||||
capturer: &mut dyn Capturer,
|
capturer: &mut dyn Capturer,
|
||||||
sock: &UdpSocket,
|
sock: &UdpSocket,
|
||||||
@@ -291,6 +373,11 @@ fn stream_body(
|
|||||||
running: &Arc<AtomicBool>,
|
running: &Arc<AtomicBool>,
|
||||||
force_idr: &AtomicBool,
|
force_idr: &AtomicBool,
|
||||||
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
||||||
|
// Shared stats recorder. The encode loop reads `stats.is_armed()` per frame to decide whether
|
||||||
|
// to accumulate the per-stage split, then emits a `StatsSample` at its 1 s aggregation boundary.
|
||||||
|
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
|
// Short client label (peer IP) seeded into the capture meta on the first armed registration.
|
||||||
|
client_label: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// The first frame establishes the authoritative size/format for the encoder.
|
// The first frame establishes the authoritative size/format for the encoder.
|
||||||
let mut frame = capturer.next_frame().context("capture first frame")?;
|
let mut frame = capturer.next_frame().context("capture first frame")?;
|
||||||
@@ -351,14 +438,34 @@ fn stream_body(
|
|||||||
|
|
||||||
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames,
|
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames,
|
||||||
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
|
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
|
||||||
let perf = std::env::var_os("PUNKTFUNK_PERF").is_some();
|
let perf = crate::config::config().perf;
|
||||||
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
|
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
|
||||||
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
|
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
|
||||||
|
// Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors
|
||||||
|
// for p50/p99, the goodput bytes queued to the sender this window, the previous window's
|
||||||
|
// dropped-frame count for delta computation, and the registration id cached on the first sample.
|
||||||
|
let codec_name = match cfg.codec {
|
||||||
|
Codec::H264 => "h264",
|
||||||
|
Codec::H265 => "hevc",
|
||||||
|
Codec::Av1 => "av1",
|
||||||
|
};
|
||||||
|
let mut sid: Option<u32> = None;
|
||||||
|
let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) =
|
||||||
|
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||||
|
let mut bytes_win: u64 = 0;
|
||||||
|
let mut last_dropped_batches: u64 = 0;
|
||||||
// Absolute next-frame deadline — the single pacing clock for the loop.
|
// Absolute next-frame deadline — the single pacing clock for the loop.
|
||||||
let mut next_frame = Instant::now();
|
let mut next_frame = Instant::now();
|
||||||
|
// RFI capability is fixed for the session (probed at encoder open). Query it once so the
|
||||||
|
// recovery path skips the always-`false` invalidate call on encoders without NVENC RFI and
|
||||||
|
// forces a keyframe directly instead.
|
||||||
|
let supports_rfi = enc.caps().supports_rfi;
|
||||||
|
|
||||||
while running.load(Ordering::SeqCst) {
|
while running.load(Ordering::SeqCst) {
|
||||||
let tick = Instant::now();
|
let tick = Instant::now();
|
||||||
|
// Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is
|
||||||
|
// armed (cheap Relaxed atomic, re-read each frame).
|
||||||
|
let measure = perf || stats.is_armed();
|
||||||
// Advance to the freshest captured frame if one arrived; otherwise reuse the last.
|
// Advance to the freshest captured frame if one arrived; otherwise reuse the last.
|
||||||
if let Some(f) = capturer.try_latest().context("capture frame")? {
|
if let Some(f) = capturer.try_latest().context("capture frame")? {
|
||||||
frame = f;
|
frame = f;
|
||||||
@@ -369,7 +476,9 @@ fn stream_body(
|
|||||||
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
||||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||||
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
|
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
|
||||||
if !enc.invalidate_ref_frames(first, last) {
|
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
|
||||||
|
// spike); otherwise — or if the range is too old to invalidate — force a keyframe.
|
||||||
|
if !(supports_rfi && enc.invalidate_ref_frames(first, last)) {
|
||||||
enc.request_keyframe();
|
enc.request_keyframe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,9 +506,19 @@ fn stream_body(
|
|||||||
// Hand the frame's packets to the send thread; never block here. A full queue means
|
// Hand the frame's packets to the send thread; never block here. A full queue means
|
||||||
// the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding.
|
// the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding.
|
||||||
let n = batch.len();
|
let n = batch.len();
|
||||||
|
// Goodput this window = bytes actually queued to the sender (a dropped batch never reaches
|
||||||
|
// the wire, so it's excluded). Summed only when measuring, to keep the idle path free.
|
||||||
|
let batch_bytes: u64 = if measure {
|
||||||
|
batch.iter().map(|p| p.len() as u64).sum()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
match batch_tx.try_send(batch) {
|
match batch_tx.try_send(batch) {
|
||||||
Ok(()) => sent_batches += 1,
|
Ok(()) => {
|
||||||
|
sent_batches += 1;
|
||||||
|
bytes_win += batch_bytes;
|
||||||
|
}
|
||||||
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
||||||
dropped_batches += 1;
|
dropped_batches += 1;
|
||||||
if dropped_batches.is_power_of_two() {
|
if dropped_batches.is_power_of_two() {
|
||||||
@@ -411,17 +530,26 @@ fn stream_body(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if perf {
|
if measure {
|
||||||
let t_send = tick.elapsed();
|
let t_send = tick.elapsed();
|
||||||
mx_cap = mx_cap.max(t_cap.as_micros());
|
let cap_us = t_cap.as_micros();
|
||||||
mx_enc = mx_enc.max((t_enc - t_cap).as_micros());
|
let enc_us = (t_enc - t_cap).as_micros();
|
||||||
mx_pkt = mx_pkt.max((t_pkt - t_enc).as_micros());
|
let pkt_us = (t_pkt - t_enc).as_micros();
|
||||||
mx_send = mx_send.max((t_send - t_pkt).as_micros());
|
let send_us = (t_send - t_pkt).as_micros();
|
||||||
|
mx_cap = mx_cap.max(cap_us);
|
||||||
|
mx_enc = mx_enc.max(enc_us);
|
||||||
|
mx_pkt = mx_pkt.max(pkt_us);
|
||||||
|
mx_send = mx_send.max(send_us);
|
||||||
mx_pkts = mx_pkts.max(n);
|
mx_pkts = mx_pkts.max(n);
|
||||||
|
v_cap.push(cap_us as u32);
|
||||||
|
v_enc.push(enc_us as u32);
|
||||||
|
v_pkt.push(pkt_us as u32);
|
||||||
|
v_send.push(send_us as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
fps_count += 1;
|
fps_count += 1;
|
||||||
if fps_t.elapsed() >= Duration::from_secs(1) {
|
if fps_t.elapsed() >= Duration::from_secs(1) {
|
||||||
|
let secs = fps_t.elapsed().as_secs_f64();
|
||||||
if perf {
|
if perf {
|
||||||
// Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device
|
// Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device
|
||||||
// copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new
|
// copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new
|
||||||
@@ -436,12 +564,6 @@ fn stream_body(
|
|||||||
max_pkts = mx_pkts,
|
max_pkts = mx_pkts,
|
||||||
"video: streaming (perf)"
|
"video: streaming (perf)"
|
||||||
);
|
);
|
||||||
mx_cap = 0;
|
|
||||||
mx_enc = 0;
|
|
||||||
mx_pkt = 0;
|
|
||||||
mx_send = 0;
|
|
||||||
mx_pkts = 0;
|
|
||||||
uniq = 0;
|
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
fps = fps_count,
|
fps = fps_count,
|
||||||
@@ -450,6 +572,68 @@ fn stream_body(
|
|||||||
"video: streaming"
|
"video: streaming"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Web-console capture: build the aggregated sample. The host send side exposes no
|
||||||
|
// receiver-side packet loss / FEC-recovery / send-buffer EAGAIN counters, so those stay
|
||||||
|
// 0 (not fabricated); `frames_dropped` is the per-frame send-queue overflow delta.
|
||||||
|
if stats.is_armed() {
|
||||||
|
let session_id = *sid.get_or_insert_with(|| {
|
||||||
|
stats.register_session(
|
||||||
|
"gamestream",
|
||||||
|
cfg.width,
|
||||||
|
cfg.height,
|
||||||
|
cfg.fps,
|
||||||
|
codec_name,
|
||||||
|
client_label,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let sample = crate::stats_recorder::StatsSample {
|
||||||
|
t_ms: 0, // stamped by push_sample from the capture's monotonic start
|
||||||
|
session_id,
|
||||||
|
stages: vec![
|
||||||
|
crate::stats_recorder::StageTiming {
|
||||||
|
name: "capture".into(),
|
||||||
|
p50_us: percentile(&mut v_cap, 0.50) as f32,
|
||||||
|
p99_us: percentile(&mut v_cap, 0.99) as f32,
|
||||||
|
},
|
||||||
|
crate::stats_recorder::StageTiming {
|
||||||
|
name: "encode".into(),
|
||||||
|
p50_us: percentile(&mut v_enc, 0.50) as f32,
|
||||||
|
p99_us: percentile(&mut v_enc, 0.99) as f32,
|
||||||
|
},
|
||||||
|
crate::stats_recorder::StageTiming {
|
||||||
|
name: "packetize".into(),
|
||||||
|
p50_us: percentile(&mut v_pkt, 0.50) as f32,
|
||||||
|
p99_us: percentile(&mut v_pkt, 0.99) as f32,
|
||||||
|
},
|
||||||
|
crate::stats_recorder::StageTiming {
|
||||||
|
name: "send".into(),
|
||||||
|
p50_us: percentile(&mut v_send, 0.50) as f32,
|
||||||
|
p99_us: percentile(&mut v_send, 0.99) as f32,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fps: (uniq as f64 / secs) as f32,
|
||||||
|
repeat_fps: (fps_count.saturating_sub(uniq) as f64 / secs) as f32,
|
||||||
|
mbps: (bytes_win as f64 * 8.0 / secs / 1_000_000.0) as f32,
|
||||||
|
bitrate_kbps: cfg.bitrate_kbps,
|
||||||
|
frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32,
|
||||||
|
packets_dropped: 0,
|
||||||
|
send_dropped: 0,
|
||||||
|
fec_recovered: 0,
|
||||||
|
};
|
||||||
|
stats.push_sample(session_id, sample);
|
||||||
|
}
|
||||||
|
mx_cap = 0;
|
||||||
|
mx_enc = 0;
|
||||||
|
mx_pkt = 0;
|
||||||
|
mx_send = 0;
|
||||||
|
mx_pkts = 0;
|
||||||
|
uniq = 0;
|
||||||
|
v_cap.clear();
|
||||||
|
v_enc.clear();
|
||||||
|
v_pkt.clear();
|
||||||
|
v_send.clear();
|
||||||
|
bytes_win = 0;
|
||||||
|
last_dropped_batches = dropped_batches;
|
||||||
fps_count = 0;
|
fps_count = 0;
|
||||||
fps_t = Instant::now();
|
fps_t = Instant::now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! `RTP_PACKET(12, big-endian) + reserved[4] + NV_VIDEO_PACKET(16, little-endian) + payload`
|
//! `RTP_PACKET(12, big-endian) + reserved[4] + NV_VIDEO_PACKET(16, little-endian) + payload`
|
||||||
//! and the frame's bitstream is prefixed with an 8-byte `video_short_frame_header_t`, then
|
//! and the frame's bitstream is prefixed with an 8-byte `video_short_frame_header_t`, then
|
||||||
//! striped into ≤4 FEC blocks of ≤255 shards. Byte-exact spec:
|
//! striped into ≤4 FEC blocks of ≤255 shards. Byte-exact spec:
|
||||||
//! `docs/research/gamestream-protocol-research.json` (video plane).
|
//! `design/research/gamestream-protocol-research.json` (video plane).
|
||||||
//!
|
//!
|
||||||
//! FEC (P1.5): each block carries `m = ⌈k·pct/100⌉` Reed–Solomon parity shards generated by
|
//! FEC (P1.5): each block carries `m = ⌈k·pct/100⌉` Reed–Solomon parity shards generated by
|
||||||
//! `punktfunk_core::fec::Gf8Coder` (the nanors-compatible Cauchy GF(2⁸) coder). Crucially, RS runs
|
//! `punktfunk_core::fec::Gf8Coder` (the nanors-compatible Cauchy GF(2⁸) coder). Crucially, RS runs
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ pub fn default_backend() -> Backend {
|
|||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
{
|
{
|
||||||
if std::env::var("PUNKTFUNK_COMPOSITOR")
|
if crate::config::config()
|
||||||
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("gamescope"))
|
.compositor
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|v| v.trim().eq_ignore_ascii_case("gamescope"))
|
||||||
{
|
{
|
||||||
return Backend::GamescopeEi;
|
return Backend::GamescopeEi;
|
||||||
}
|
}
|
||||||
@@ -260,8 +262,10 @@ fn coalesce(events: Vec<InputEvent>) -> Vec<InputEvent> {
|
|||||||
/// (`org.gnome.Mutter.RemoteDesktop`), the same direct API the Mutter video backend uses.
|
/// (`org.gnome.Mutter.RemoteDesktop`), the same direct API the Mutter video backend uses.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn libei_ei_source() -> libei::EiSource {
|
fn libei_ei_source() -> libei::EiSource {
|
||||||
let gnome = std::env::var("PUNKTFUNK_COMPOSITOR")
|
let gnome = crate::config::config()
|
||||||
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("mutter"))
|
.compositor
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|v| v.trim().eq_ignore_ascii_case("mutter"))
|
||||||
|| std::env::var("XDG_CURRENT_DESKTOP")
|
|| std::env::var("XDG_CURRENT_DESKTOP")
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_uppercase()
|
.to_ascii_uppercase()
|
||||||
@@ -421,24 +425,46 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput
|
||||||
|
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
|
||||||
|
// `#[path]` keeps every `crate::inject::*` module name flat.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/linux/dualsense.rs"]
|
||||||
pub mod dualsense;
|
pub mod dualsense;
|
||||||
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
|
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
|
||||||
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
#[path = "inject/proto/dualsense_proto.rs"]
|
||||||
pub mod dualsense_proto;
|
pub mod dualsense_proto;
|
||||||
/// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel.
|
/// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "inject/windows/dualsense_windows.rs"]
|
||||||
pub mod dualsense_windows;
|
pub mod dualsense_windows;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/linux/dualshock4.rs"]
|
||||||
pub mod dualshock4;
|
pub mod dualshock4;
|
||||||
#[cfg(target_os = "linux")]
|
/// Transport-independent DualShock 4 HID codec used by the Windows UMDF-driver backend
|
||||||
pub mod gamepad;
|
/// ([`dualshock4_windows`]). (The Linux backend still carries its own copy — see the module FIXME.)
|
||||||
/// Windows: virtual Xbox 360 pads via ViGEmBus.
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
#[path = "inject/proto/dualshock4_proto.rs"]
|
||||||
|
pub mod dualshock4_proto;
|
||||||
|
/// Windows: virtual DualShock 4 via the same UMDF minidriver + shared-memory channel (device-type 1).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "inject/gamepad_windows.rs"]
|
#[path = "inject/windows/dualshock4_windows.rs"]
|
||||||
|
pub mod dualshock4_windows;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/linux/gamepad.rs"]
|
||||||
pub mod gamepad;
|
pub mod gamepad;
|
||||||
/// Stub — virtual gamepads need Linux uinput or Windows ViGEmBus; events are dropped elsewhere.
|
/// Windows: virtual Xbox 360 pads via the in-tree XUSB companion UMDF driver (classic XInput).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "inject/windows/gamepad_windows.rs"]
|
||||||
|
pub mod gamepad;
|
||||||
|
/// Windows: small RAII wrappers (`Shm` section+view, `SwDevice` devnode) shared by the three gamepad
|
||||||
|
/// backends (DualSense / DualShock 4 / XUSB), so each per-pad resource closes deterministically on drop.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "inject/windows/gamepad_raii.rs"]
|
||||||
|
mod gamepad_raii;
|
||||||
|
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
pub mod gamepad {
|
pub mod gamepad {
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -452,10 +478,13 @@ pub mod gamepad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/linux/libei.rs"]
|
||||||
mod libei;
|
mod libei;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "inject/windows/sendinput.rs"]
|
||||||
mod sendinput;
|
mod sendinput;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/linux/wlr.rs"]
|
||||||
mod wlr;
|
mod wlr;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
//! Windows virtual gamepad via ViGEmBus — the analogue of the Linux uinput Xbox-360 pad.
|
|
||||||
//! One virtual Xbox 360 controller per client pad index. GameStream/Moonlight already uses the
|
|
||||||
//! XInput button/stick/trigger conventions (low 16 button bits, sticks −32768..32767 +Y up,
|
|
||||||
//! triggers 0..255), so the mapping is ~1:1.
|
|
||||||
//!
|
|
||||||
//! Needs the ViGEmBus driver installed (like SudoVDA for the display); absent → gamepad is disabled
|
|
||||||
//! and the session continues without it. Rumble flows back the *other* way: a game on the host writes
|
|
||||||
//! force-feedback to the virtual pad, ViGEm's notification API delivers it on a background thread,
|
|
||||||
//! and [`GamepadManager::pump_rumble`] relays level changes to the client (the universal 0xCA plane),
|
|
||||||
//! mirroring the Linux `EV_FF` read path.
|
|
||||||
|
|
||||||
use crate::gamestream::gamepad::GamepadEvent;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::thread::JoinHandle;
|
|
||||||
use vigem_client::{Client, TargetId, XButtons, XGamepad, Xbox360Wired};
|
|
||||||
|
|
||||||
/// A plugged virtual pad plus its rumble back-channel. The notification thread stores the latest
|
|
||||||
/// motor levels into `rumble` (packed `large << 8 | small`, both 0..255); [`GamepadManager::pump_rumble`]
|
|
||||||
/// reads it and emits level changes. Dropping `target` aborts the outstanding notification request,
|
|
||||||
/// so the thread's `poll` returns an error and it exits on its own — we detach it (per ViGEm's docs,
|
|
||||||
/// dropping the `JoinHandle` does not stop the thread, but the target-drop abort does).
|
|
||||||
struct PadEntry {
|
|
||||||
target: Xbox360Wired<Arc<Client>>,
|
|
||||||
rumble: Arc<AtomicU32>,
|
|
||||||
last_emitted: u32,
|
|
||||||
_notif_thread: Option<JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GamepadManager {
|
|
||||||
client: Option<Arc<Client>>,
|
|
||||||
pads: HashMap<u8, PadEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GamepadManager {
|
|
||||||
pub fn new() -> GamepadManager {
|
|
||||||
let client = match Client::connect() {
|
|
||||||
Ok(c) => {
|
|
||||||
tracing::info!("ViGEmBus connected (virtual Xbox 360 gamepads)");
|
|
||||||
Some(Arc::new(c))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
error = format!("{e:?}"),
|
|
||||||
"ViGEmBus unavailable — gamepad disabled (install ViGEmBus)"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
GamepadManager {
|
|
||||||
client,
|
|
||||||
pads: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lazily plug pad `index` on its first event, arming the rumble notification thread. Returns
|
|
||||||
/// `None` if ViGEmBus is unavailable or the pad failed to plug.
|
|
||||||
fn ensure_pad(&mut self, index: u8) -> Option<&mut PadEntry> {
|
|
||||||
if !self.pads.contains_key(&index) {
|
|
||||||
let client = self.client.clone()?;
|
|
||||||
let mut target = Xbox360Wired::new(client, TargetId::XBOX360_WIRED);
|
|
||||||
if let Err(e) = target.plugin() {
|
|
||||||
tracing::warn!(error = format!("{e:?}"), "ViGEm pad plugin failed");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let _ = target.wait_ready();
|
|
||||||
// Arm the force-feedback back-channel: a background thread writes each notification's
|
|
||||||
// motor levels into the shared atomic; the input thread drains changes via pump_rumble.
|
|
||||||
let rumble = Arc::new(AtomicU32::new(0));
|
|
||||||
let notif_thread = match target.request_notification() {
|
|
||||||
Ok(req) => {
|
|
||||||
let sink = rumble.clone();
|
|
||||||
Some(req.spawn_thread(move |_req, n| {
|
|
||||||
sink.store(
|
|
||||||
((n.large_motor as u32) << 8) | n.small_motor as u32,
|
|
||||||
Ordering::Relaxed,
|
|
||||||
);
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
error = format!("{e:?}"),
|
|
||||||
"ViGEm rumble notification unavailable — pad runs without force feedback"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.pads.insert(
|
|
||||||
index,
|
|
||||||
PadEntry {
|
|
||||||
target,
|
|
||||||
rumble,
|
|
||||||
last_emitted: 0,
|
|
||||||
_notif_thread: notif_thread,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
self.pads.get_mut(&index)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
|
||||||
let GamepadEvent::State(f) = ev else {
|
|
||||||
return; // Arrival metadata — the pad is created lazily on the first State
|
|
||||||
};
|
|
||||||
let Some(entry) = self.ensure_pad(f.index.max(0) as u8) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let gp = XGamepad {
|
|
||||||
buttons: XButtons {
|
|
||||||
raw: (f.buttons & 0xffff) as u16,
|
|
||||||
},
|
|
||||||
left_trigger: f.left_trigger,
|
|
||||||
right_trigger: f.right_trigger,
|
|
||||||
thumb_lx: f.ls_x,
|
|
||||||
thumb_ly: f.ls_y,
|
|
||||||
thumb_rx: f.rs_x,
|
|
||||||
thumb_ry: f.rs_y,
|
|
||||||
};
|
|
||||||
let _ = entry.target.update(&gp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Relay any changed rumble level to the client. The notification thread keeps `rumble` current;
|
|
||||||
/// we emit only on change (the input thread re-sends the steady state every 500 ms to heal drops).
|
|
||||||
/// ViGEm motors are 0..255; the wire carries 0..65535, so scale by 257 (255 → 65535). `large`
|
|
||||||
/// (low-frequency) maps to the universal datagram's `low`, `small` (high-frequency) to `high`.
|
|
||||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
|
||||||
for (idx, entry) in self.pads.iter_mut() {
|
|
||||||
let packed = entry.rumble.load(Ordering::Relaxed);
|
|
||||||
if packed != entry.last_emitted {
|
|
||||||
entry.last_emitted = packed;
|
|
||||||
let large = ((packed >> 8) & 0xff) as u16;
|
|
||||||
let small = (packed & 0xff) as u16;
|
|
||||||
send(*idx as u16, large * 257, small * 257);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
@@ -258,6 +258,7 @@ impl DualShock4Pad {
|
|||||||
// union (uhid_create2_req) starts at byte 4.
|
// union (uhid_create2_req) starts at byte 4.
|
||||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualShock 4 {index}")); // name[128]
|
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualShock 4 {index}")); // name[128]
|
||||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualshock4/{index}")); // phys[64]
|
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualshock4/{index}")); // phys[64]
|
||||||
|
|
||||||
// A unique uniq[64] keeps the sysfs nodes tidy when several pads coexist (the kernel's
|
// A unique uniq[64] keeps the sysfs nodes tidy when several pads coexist (the kernel's
|
||||||
// duplicate-device check itself keys off the per-pad MAC in the pairing feature report).
|
// duplicate-device check itself keys off the per-pad MAC in the pairing feature report).
|
||||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64]
|
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64]
|
||||||
+43
@@ -15,6 +15,9 @@
|
|||||||
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
|
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
|
||||||
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
|
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
|
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -215,6 +218,11 @@ const _: () = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> {
|
fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> {
|
||||||
|
// SAFETY: every caller passes one of UI_SET_EVBIT/KEYBIT/FFBIT/UI_DEV_CREATE/UI_DEV_DESTROY as
|
||||||
|
// `req` — all integer-argument ioctls whose third arg the kernel takes BY VALUE, so nothing is
|
||||||
|
// dereferenced through `arg` and no memory must outlive the call. The only precondition is `fd`
|
||||||
|
// being a valid open descriptor; callers pass the live `/dev/uinput` fd, and even a stale fd
|
||||||
|
// would merely return -1/EBADF (reported below), never UB.
|
||||||
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
||||||
bail!("{what}: {}", std::io::Error::last_os_error());
|
bail!("{what}: {}", std::io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
@@ -222,6 +230,12 @@ fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> {
|
fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> {
|
||||||
|
// SAFETY: `fd` is the caller's live `/dev/uinput` fd. Every call site passes `&mut x` for a live,
|
||||||
|
// uniquely-borrowed `#[repr(C)]` `x: T` whose size matches the struct the request number encodes
|
||||||
|
// (UI_DEV_SETUP=0x405c_5503 → 0x5c=92=size_of::<UinputSetup>(); UI_ABS_SETUP → 0x1c=28; the FF
|
||||||
|
// upload/erase ioctls → 0x68/0x0c — all pinned by the `size_of` asserts above). The kernel copies
|
||||||
|
// exactly that many bytes in/out through `arg`; the `&mut` keeps the pointee alive and unaliased
|
||||||
|
// for the whole synchronous call.
|
||||||
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
||||||
bail!("{what}: {}", std::io::Error::last_os_error());
|
bail!("{what}: {}", std::io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
@@ -251,6 +265,9 @@ pub struct VirtualPad {
|
|||||||
impl VirtualPad {
|
impl VirtualPad {
|
||||||
pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
|
pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
|
||||||
use std::os::fd::FromRawFd;
|
use std::os::fd::FromRawFd;
|
||||||
|
// SAFETY: `c"/dev/uinput"` is a 'static NUL-terminated C string literal; `as_ptr()` yields a
|
||||||
|
// valid pointer the kernel only reads as a filesystem path. `open` returns a fresh fd (or -1)
|
||||||
|
// and retains nothing; no Rust memory is aliased or handed to the kernel beyond that 'static path.
|
||||||
let raw = unsafe {
|
let raw = unsafe {
|
||||||
libc::open(
|
libc::open(
|
||||||
c"/dev/uinput".as_ptr(),
|
c"/dev/uinput".as_ptr(),
|
||||||
@@ -264,6 +281,9 @@ impl VirtualPad {
|
|||||||
std::io::Error::last_os_error()
|
std::io::Error::last_os_error()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// SAFETY: `raw >= 0` here (the `< 0` branch above already bailed), so it is a freshly-opened fd
|
||||||
|
// from `libc::open` that is not stored or owned anywhere else. Transferring it to `OwnedFd` makes
|
||||||
|
// this the unique owner, which will `close` it exactly once on drop (no double-close, no leak).
|
||||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||||
|
|
||||||
ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?;
|
ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?;
|
||||||
@@ -356,6 +376,11 @@ impl VirtualPad {
|
|||||||
code,
|
code,
|
||||||
value,
|
value,
|
||||||
};
|
};
|
||||||
|
// SAFETY: `ev` is a live local `#[repr(C)]` struct of all-integer fields with no padding bytes
|
||||||
|
// (timeval=16 + u16 + u16 + i32 = 24, the size asserted above), so every byte is initialized and
|
||||||
|
// valid to read as `u8`. The pointer is non-null and `u8`-aligned (align 1), the length is exactly
|
||||||
|
// `size_of::<InputEventRaw>()` so the slice spans precisely `ev`'s bytes (in bounds), and `ev`
|
||||||
|
// outlives `bytes` (used immediately below) with no concurrent mutation (single-threaded local).
|
||||||
let bytes = unsafe {
|
let bytes = unsafe {
|
||||||
std::slice::from_raw_parts(
|
std::slice::from_raw_parts(
|
||||||
&ev as *const _ as *const u8,
|
&ev as *const _ as *const u8,
|
||||||
@@ -363,6 +388,10 @@ impl VirtualPad {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
|
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
|
||||||
|
// SAFETY: `self.fd` is the live uinput `OwnedFd` (borrowed via `as_raw_fd`, so it stays open for
|
||||||
|
// the call); `bytes` is the slice above backed by the still-live local `ev`. `write` only READS
|
||||||
|
// exactly `bytes.len()` bytes from `bytes.as_ptr()` (in bounds) and retains nothing past return,
|
||||||
|
// so the buffer outlives the synchronous call and the read-only access cannot race or alias.
|
||||||
let _ = unsafe {
|
let _ = unsafe {
|
||||||
libc::write(
|
libc::write(
|
||||||
self.fd.as_raw_fd(),
|
self.fd.as_raw_fd(),
|
||||||
@@ -404,6 +433,10 @@ impl VirtualPad {
|
|||||||
let raw = self.fd.as_raw_fd();
|
let raw = self.fd.as_raw_fd();
|
||||||
let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()];
|
let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()];
|
||||||
loop {
|
loop {
|
||||||
|
// SAFETY: `raw` is the live raw fd of `self.fd` (the non-blocking uinput device). `buf` is a
|
||||||
|
// live local `[u8; size_of::<InputEventRaw>()]`; `buf.as_mut_ptr()` is a valid writable pointer
|
||||||
|
// to its `buf.len()` bytes. `read` writes AT MOST `buf.len()` bytes (in bounds), the buffer
|
||||||
|
// outlives this synchronous call, and `buf` is borrowed uniquely here (no alias/race).
|
||||||
let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
|
let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
|
||||||
if n != buf.len() as isize {
|
if n != buf.len() as isize {
|
||||||
break; // EAGAIN / short read — queue drained
|
break; // EAGAIN / short read — queue drained
|
||||||
@@ -415,6 +448,10 @@ impl VirtualPad {
|
|||||||
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
|
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
|
||||||
match (ev.type_, ev.code) {
|
match (ev.type_, ev.code) {
|
||||||
(EV_UINPUT, UI_FF_UPLOAD) => {
|
(EV_UINPUT, UI_FF_UPLOAD) => {
|
||||||
|
// SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two
|
||||||
|
// `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field
|
||||||
|
// (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid
|
||||||
|
// value — `request_id` is then set below and the rest filled by UI_BEGIN_FF_UPLOAD.
|
||||||
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
||||||
up.request_id = ev.value as u32;
|
up.request_id = ev.value as u32;
|
||||||
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
||||||
@@ -442,6 +479,9 @@ impl VirtualPad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(EV_UINPUT, UI_FF_ERASE) => {
|
(EV_UINPUT, UI_FF_ERASE) => {
|
||||||
|
// SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`,
|
||||||
|
// `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid
|
||||||
|
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
|
||||||
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
|
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
|
||||||
er.request_id = ev.value as u32;
|
er.request_id = ev.value as u32;
|
||||||
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
|
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
|
||||||
@@ -492,6 +532,9 @@ impl VirtualPad {
|
|||||||
|
|
||||||
impl Drop for VirtualPad {
|
impl Drop for VirtualPad {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.fd` is still the live owned uinput fd here (the `OwnedFd` field is closed only
|
||||||
|
// AFTER this `drop` body returns), borrowed by `as_raw_fd`. UI_DEV_DESTROY takes its argument
|
||||||
|
// (0) BY VALUE, so nothing is dereferenced or aliased; the ioctl just tears down the device.
|
||||||
let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) };
|
let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+10
@@ -5,6 +5,9 @@
|
|||||||
//! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state
|
//! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state
|
||||||
//! so the compositor resolves shifted keysyms correctly.
|
//! so the compositor resolves shifted keysyms correctly.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use punktfunk_core::input::InputKind;
|
use punktfunk_core::input::InputKind;
|
||||||
@@ -264,10 +267,17 @@ impl InputInjector for WlrootsInjector {
|
|||||||
/// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd).
|
/// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd).
|
||||||
fn memfd_with(s: &str) -> Result<std::fs::File> {
|
fn memfd_with(s: &str) -> Result<std::fs::File> {
|
||||||
let name = b"punktfunk-keymap\0";
|
let name = b"punktfunk-keymap\0";
|
||||||
|
// SAFETY: `name` is a byte-string literal with an explicit trailing NUL, so `name.as_ptr()` is a
|
||||||
|
// valid NUL-terminated C string; `memfd_create` only reads that name (copying it) and creates an
|
||||||
|
// anonymous file, returning a fresh fd (or -1). `MFD_CLOEXEC` is a valid flag. The 'static literal
|
||||||
|
// outlives the synchronous call and nothing aliases it. The result is checked `< 0` below.
|
||||||
let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) };
|
let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) };
|
||||||
if fd < 0 {
|
if fd < 0 {
|
||||||
bail!("memfd_create failed: {}", std::io::Error::last_os_error());
|
bail!("memfd_create failed: {}", std::io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
|
// SAFETY: `fd` is the fresh memfd `memfd_create` just returned and checked `>= 0`; it is a unique
|
||||||
|
// open fd nothing else owns, so `File` takes sole ownership and closes it exactly once on drop —
|
||||||
|
// no alias, no double-close.
|
||||||
let mut f = unsafe { std::fs::File::from_raw_fd(fd) };
|
let mut f = unsafe { std::fs::File::from_raw_fd(fd) };
|
||||||
f.write_all(s.as_bytes()).context("write keymap")?;
|
f.write_all(s.as_bytes()).context("write keymap")?;
|
||||||
f.write_all(&[0]).context("write keymap NUL")?;
|
f.write_all(&[0]).context("write keymap NUL")?;
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//! Transport-independent DualShock 4 HID contract — the pure report codec used by the Windows
|
||||||
|
//! UMDF-driver backend ([`super::dualshock4_windows`]).
|
||||||
|
//!
|
||||||
|
//! FIXME(ds4-dedup): the Linux UHID backend ([`super::dualshock4`]) still carries its own byte-
|
||||||
|
//! identical copy of this codec (`serialize_state` / `parse_ds4_output` / `Ds4Feedback` / the touch
|
||||||
|
//! dims). Fold it onto this module once the Linux build can be re-validated (it is `cfg(linux)`, so
|
||||||
|
//! it can't be compile-checked from a Windows host). Keep the two in sync until then.
|
||||||
|
//!
|
||||||
|
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
|
||||||
|
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
|
||||||
|
//! ([`DsState::from_gamepad`]) — only the report *byte layout*, the touchpad resolution, and the
|
||||||
|
//! feedback report differ. The Linux backend writes report `0x01` to `/dev/uhid` and reads `0x05` via
|
||||||
|
//! `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and pulls `0x05` back over its
|
||||||
|
//! shared-memory channel — both build/parse the exact same bytes here.
|
||||||
|
//!
|
||||||
|
//! Field offsets are the canonical real-DS4-USB layout the kernel `struct
|
||||||
|
//! dualshock4_input_report_usb` / `_output_report_common` parse.
|
||||||
|
|
||||||
|
use super::dualsense_proto::{DsState, Touch};
|
||||||
|
use punktfunk_core::quic::HidOutput;
|
||||||
|
|
||||||
|
/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2).
|
||||||
|
pub const DS4_VENDOR: u16 = 0x054C;
|
||||||
|
pub const DS4_PRODUCT: u16 = 0x09CC;
|
||||||
|
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
||||||
|
pub const DS4_INPUT_REPORT_LEN: usize = 64;
|
||||||
|
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
|
||||||
|
/// than the DualSense's 1920×1080.
|
||||||
|
pub const DS4_TOUCH_W: u16 = 1920;
|
||||||
|
pub const DS4_TOUCH_H: u16 = 942;
|
||||||
|
|
||||||
|
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
|
||||||
|
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
|
||||||
|
fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||||
|
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
|
||||||
|
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
|
||||||
|
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
|
||||||
|
dst[1] = (x & 0xFF) as u8;
|
||||||
|
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
|
||||||
|
dst[3] = ((y >> 4) & 0xFF) as u8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without a transport). Field offsets
|
||||||
|
/// per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch; touch[3];
|
||||||
|
/// rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp; gyro[3] le16;
|
||||||
|
/// accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common` field at struct
|
||||||
|
/// offset N sits at report byte N+1.
|
||||||
|
pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
|
||||||
|
r[0] = 0x01; // report id
|
||||||
|
r[1] = st.lx;
|
||||||
|
r[2] = st.ly;
|
||||||
|
r[3] = st.rx;
|
||||||
|
r[4] = st.ry;
|
||||||
|
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
||||||
|
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
||||||
|
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
|
||||||
|
r[8] = st.l2; // L2 analog (z)
|
||||||
|
r[9] = st.r2; // R2 analog (rz)
|
||||||
|
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
||||||
|
// r[12] temperature stays 0
|
||||||
|
for (i, v) in st.gyro.iter().enumerate() {
|
||||||
|
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
|
||||||
|
}
|
||||||
|
for (i, v) in st.accel.iter().enumerate() {
|
||||||
|
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
|
||||||
|
}
|
||||||
|
// r[25..30] reserved2.
|
||||||
|
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
|
||||||
|
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
|
||||||
|
r[30] = 0x10 | 0x0B;
|
||||||
|
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
|
||||||
|
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
|
||||||
|
r[34] = ts as u8; // touch_reports[0].timestamp
|
||||||
|
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
|
||||||
|
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
|
||||||
|
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal
|
||||||
|
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player LEDs or adaptive
|
||||||
|
/// triggers, so those never appear).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Ds4Feedback {
|
||||||
|
pub hidout: Vec<HidOutput>,
|
||||||
|
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
|
||||||
|
pub rumble: Option<(u16, u16)>,
|
||||||
|
/// Lightbar RGB, if the report carried it (deduped by the manager).
|
||||||
|
pub led: Option<(u8, u8, u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
|
||||||
|
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
|
||||||
|
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
|
||||||
|
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
|
||||||
|
/// doesn't masquerade as a lightbar change.
|
||||||
|
pub fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
|
||||||
|
if data.first() != Some(&0x05) || data.len() < 11 {
|
||||||
|
return; // not the USB output report (BT 0x11 is shifted) / too short
|
||||||
|
}
|
||||||
|
let flag0 = data[1];
|
||||||
|
if flag0 & 0x01 != 0 {
|
||||||
|
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
|
||||||
|
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
|
||||||
|
let low = (data[5] as u16) << 8;
|
||||||
|
let high = (data[4] as u16) << 8;
|
||||||
|
fb.rumble = Some((low, high));
|
||||||
|
}
|
||||||
|
if flag0 & 0x02 != 0 {
|
||||||
|
fb.led = Some((data[6], data[7], data[8]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets.
|
||||||
|
#[test]
|
||||||
|
fn serialize_offsets() {
|
||||||
|
use punktfunk_core::input::gamepad as gs;
|
||||||
|
let mut st = DsState::from_gamepad(
|
||||||
|
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
|
||||||
|
16384, // lx (right)
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
-32768, // ry (down) — inverted to 0xFF
|
||||||
|
200, // L2
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
st.gyro = [0x0102, 0x0304, 0x0506];
|
||||||
|
st.accel = [0x1112, 0x1314, 0x1516];
|
||||||
|
st.touch[0] = Touch {
|
||||||
|
active: true,
|
||||||
|
id: 0,
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
};
|
||||||
|
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||||
|
serialize_state(&mut r, &st, 0, 0);
|
||||||
|
assert_eq!(r[0], 0x01); // report id
|
||||||
|
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
|
||||||
|
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
|
||||||
|
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
|
||||||
|
assert_eq!(r[6] & 0x01, 0x01); // L1
|
||||||
|
// gyro le16 at 13..19, accel le16 at 19..25.
|
||||||
|
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
|
||||||
|
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
|
||||||
|
assert_eq!(r[33], 1); // one touch frame
|
||||||
|
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
|
||||||
|
assert_eq!(r[35] & 0x7F, 0); // contact id 0
|
||||||
|
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
|
||||||
|
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
|
||||||
|
#[test]
|
||||||
|
fn parse_output_rumble_and_lightbar() {
|
||||||
|
let mut report = [0u8; 32];
|
||||||
|
report[0] = 0x05;
|
||||||
|
report[1] = 0x01 | 0x02; // MOTOR | LED
|
||||||
|
report[4] = 0x40; // motor_right (weak/high)
|
||||||
|
report[5] = 0x80; // motor_left (strong/low)
|
||||||
|
report[6] = 0x11; // R
|
||||||
|
report[7] = 0x22; // G
|
||||||
|
report[8] = 0x33; // B
|
||||||
|
let mut fb = Ds4Feedback::default();
|
||||||
|
parse_ds4_output(&report, &mut fb);
|
||||||
|
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
|
||||||
|
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
|
||||||
|
|
||||||
|
let mut motor_only = [0u8; 32];
|
||||||
|
motor_only[0] = 0x05;
|
||||||
|
motor_only[1] = 0x01; // MOTOR only
|
||||||
|
motor_only[5] = 0x10;
|
||||||
|
let mut fb2 = Ds4Feedback::default();
|
||||||
|
parse_ds4_output(&motor_only, &mut fb2);
|
||||||
|
assert!(fb2.rumble.is_some());
|
||||||
|
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
|
||||||
|
}
|
||||||
|
}
|
||||||
+129
-96
@@ -25,37 +25,39 @@ 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, HRESULT, HSTRING, PCWSTR};
|
use windows::core::{w, GUID, HRESULT, HSTRING, 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,
|
||||||
};
|
};
|
||||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
|
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||||
use windows::Win32::Security::Authorization::{
|
|
||||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
|
||||||
};
|
|
||||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
|
||||||
use windows::Win32::System::Memory::{
|
|
||||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
|
||||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
|
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
|
||||||
|
|
||||||
/// Shared-section layout — must match `packaging/windows/dualsense-driver/src/lib.rs`.
|
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
|
||||||
const SHM_SIZE: usize = 256;
|
/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
|
||||||
const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS"
|
/// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so
|
||||||
const OFF_INPUT: usize = 8;
|
/// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
|
||||||
const OFF_OUT_SEQ: usize = 72;
|
pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>();
|
||||||
const OFF_OUTPUT: usize = 76;
|
pub(super) const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
|
||||||
|
pub(super) const OFF_INPUT: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, input);
|
||||||
|
pub(super) const OFF_OUT_SEQ: usize =
|
||||||
|
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_seq);
|
||||||
|
pub(super) const OFF_OUTPUT: usize =
|
||||||
|
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, output);
|
||||||
|
/// Device-type selector the driver reads to choose which HID identity/descriptor it serves: 0 =
|
||||||
|
/// DualSense (the default — the section is zeroed), 1 = DualShock 4.
|
||||||
|
pub(super) const OFF_DEVTYPE: usize =
|
||||||
|
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
|
||||||
|
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 shared-memory section the driver maps.
|
||||||
/// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section.
|
/// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section.
|
||||||
struct DsWinPad {
|
struct DsWinPad {
|
||||||
/// Per-session devnode from SwDeviceCreate, when it succeeds. `None` falls back to an out-of-band
|
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||||
/// `pf_dualsense` devnode (installer/devgen).
|
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
|
||||||
hsw: Option<HSWDEVICE>,
|
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||||
map: HANDLE,
|
/// The named shared section the driver maps (RAII — unmapped + closed on drop).
|
||||||
view: *mut u8,
|
shm: super::gamepad_raii::Shm,
|
||||||
seq: u8,
|
seq: u8,
|
||||||
ts: u32,
|
ts: u32,
|
||||||
last_out_seq: u32,
|
last_out_seq: u32,
|
||||||
@@ -86,31 +88,103 @@ unsafe extern "system" fn sw_create_cb(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the per-session virtual DualSense devnode for pad `index` under enumerator `punktfunk`
|
/// The PnP identity for a virtual controller devnode — varies by controller type so the same
|
||||||
/// (instance `pf_pad_<index>`, hardware id `pf_dualsense` which the INF matches). The returned
|
/// [`create_swdevice`] builds a DualSense (`VID_054C&PID_0CE6`) or a DualShock 4
|
||||||
/// `HSWDEVICE` owns it — `SwDeviceClose` removes it on drop, so the pad appears/disappears with the
|
/// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below.
|
||||||
/// session and nothing persists.
|
pub(super) struct SwDeviceProfile<'a> {
|
||||||
|
/// PnP instance id — distinct namespaces per type (`pf_pad_<idx>` vs `pf_ds4_<idx>`) so the two
|
||||||
|
/// never reuse the same devnode shell.
|
||||||
|
pub instance: &'a str,
|
||||||
|
/// Index for the deterministic per-pad ContainerId.
|
||||||
|
pub container_index: u8,
|
||||||
|
/// The INF-matched hardware id (`pf_dualsense` / `pf_dualshock4`), listed FIRST so the INF binds.
|
||||||
|
pub hwid: &'a str,
|
||||||
|
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
|
||||||
|
pub usb_vid_pid: &'a str,
|
||||||
|
/// Device description shown in Device Manager.
|
||||||
|
pub description: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the per-session virtual controller devnode under enumerator `punktfunk` (instance
|
||||||
|
/// `profile.instance`). The returned `HSWDEVICE` owns it — `SwDeviceClose` removes it on drop, so the
|
||||||
|
/// pad appears/disappears with the session and nothing persists.
|
||||||
|
///
|
||||||
|
/// **Game-detection identity** (see `design/windows-dualsense-game-detection.md`). `HIDD_ATTRIBUTES`
|
||||||
|
/// alone (VID/PID via the IOCTL) satisfies SDL/HIDAPI/RawInput, but a native PS5 path (libScePad-
|
||||||
|
/// style raw HID) classifies the *connection type* by walking from the HID child to its parent
|
||||||
|
/// (`CM_Get_Parent`) and string-matching `"USB"`/`"BTHENUM"` in that parent's
|
||||||
|
/// `DEVPKEY_Device_CompatibleIds`; with no bus identity the pad reads as `UNKNOWN` and the native
|
||||||
|
/// path rejects it. So we set, via `SW_DEVICE_CREATE_INFO` (NOT `pProperties` — bus/identity info is
|
||||||
|
/// create-time-only and a `DEVPROPERTY` write of these keys is ignored):
|
||||||
|
/// - `pszzCompatibleIds` starting with a `USB\` token → the parent walk resolves `bus_type = USB`.
|
||||||
|
/// - `pszzHardwareIds` = `pf_dualsense` **first** (so the INF still binds our UMDF driver) followed
|
||||||
|
/// by `USB\VID_054C&PID_0CE6[&REV_0100]`, which makes hidclass derive the real-DualSense child
|
||||||
|
/// hardware ids `HID\VID_054C&PID_0CE6[&REV_0100]` (the set a genuine USB DS5 exposes).
|
||||||
|
/// - a deterministic, non-sentinel per-pad `pContainerId` (groups the pad's devnodes; avoids the
|
||||||
|
/// null-sentinel ContainerId that trips an `xinput1_4` slot-skip bug).
|
||||||
|
///
|
||||||
|
/// (Validated live on `.173`: the INF still binds, the child gains the `HID\VID&PID` ids, and the
|
||||||
|
/// parent walk reports USB. Remaining gap: GameInput parses VID/PID from the child *instance path*
|
||||||
|
/// `HID\punktfunk\…`, which only a real USB-bus instance path — a bus driver — would change.)
|
||||||
///
|
///
|
||||||
/// Two requirements each yield E_INVALIDARG if violated: the enumerator name must not contain `_`
|
/// Two requirements each yield E_INVALIDARG if violated: the enumerator name must not contain `_`
|
||||||
/// (hence `punktfunk`, not `pf_dualsense`), and the completion callback is mandatory (the docs mark
|
/// (hence `punktfunk`, not `pf_dualsense`), and the completion callback is mandatory (the docs mark
|
||||||
/// `pCallback` as `[in]`, not optional — a NULL callback is rejected). The caller must be
|
/// `pCallback` as `[in]`, not optional — a NULL callback is rejected). The caller must be
|
||||||
/// Administrator (the host service runs as LocalSystem).
|
/// Administrator (the host service runs as LocalSystem).
|
||||||
fn create_swdevice(index: u8) -> Result<HSWDEVICE> {
|
pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<HSWDEVICE> {
|
||||||
let hwids: Vec<u16> = "pf_dualsense".encode_utf16().chain([0u16, 0u16]).collect();
|
// Build a double-NUL-terminated UTF-16 multi-sz from a list of ids.
|
||||||
let instid: Vec<u16> = format!("pf_pad_{index}")
|
let multi_sz = |ids: &[&str]| -> Vec<u16> {
|
||||||
|
ids.iter()
|
||||||
|
.flat_map(|s| s.encode_utf16().chain(std::iter::once(0)))
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let usb_rev = format!("USB\\{}&REV_0100", p.usb_vid_pid);
|
||||||
|
let usb = format!("USB\\{}", p.usb_vid_pid);
|
||||||
|
let hwids = multi_sz(&[
|
||||||
|
p.hwid, // FIRST → the INF binds our UMDF driver on this id
|
||||||
|
usb_rev.as_str(),
|
||||||
|
usb.as_str(),
|
||||||
|
]);
|
||||||
|
let compat = multi_sz(&[
|
||||||
|
usb.as_str(), // a `USB\` token → native bus-type detection resolves USB
|
||||||
|
"USB\\Class_03&SubClass_00&Prot_00",
|
||||||
|
"USB\\Class_03",
|
||||||
|
]);
|
||||||
|
let instid: Vec<u16> = p
|
||||||
|
.instance
|
||||||
.encode_utf16()
|
.encode_utf16()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect();
|
.collect();
|
||||||
let desc: Vec<u16> = "punktfunk Virtual DualSense"
|
let desc: Vec<u16> = p
|
||||||
|
.description
|
||||||
.encode_utf16()
|
.encode_utf16()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect();
|
.collect();
|
||||||
// SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version.
|
// The pad index, stamped into the device Location — the driver reads it to map `pfds-shm-<index>`
|
||||||
|
// (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return).
|
||||||
|
let loc: Vec<u16> = format!("{}", p.container_index)
|
||||||
|
.encode_utf16()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
// Deterministic per-pad ContainerId {50464453-0000-0000-0000-0000000000<idx>} ("PFDS").
|
||||||
|
let container = GUID::from_values(
|
||||||
|
0x5046_4453,
|
||||||
|
0x0000,
|
||||||
|
0x0000,
|
||||||
|
[0, 0, 0, 0, 0, 0, 0, p.container_index],
|
||||||
|
);
|
||||||
|
|
||||||
|
// SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version. The id
|
||||||
|
// buffers and `container` outlive the SwDeviceCreate call (we wait on the event before return).
|
||||||
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
|
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
|
||||||
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
|
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
|
||||||
info.pszInstanceId = PCWSTR(instid.as_ptr());
|
info.pszInstanceId = PCWSTR(instid.as_ptr());
|
||||||
info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
|
info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
|
||||||
|
info.pszzCompatibleIds = PCWSTR(compat.as_ptr());
|
||||||
|
info.pContainerId = &container;
|
||||||
info.pszDeviceDescription = PCWSTR(desc.as_ptr());
|
info.pszDeviceDescription = PCWSTR(desc.as_ptr());
|
||||||
|
info.pszDeviceLocation = PCWSTR(loc.as_ptr());
|
||||||
info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable
|
info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable
|
||||||
|
|
||||||
// SAFETY: a manual-reset, initially-unsignaled, unnamed event.
|
// SAFETY: a manual-reset, initially-unsignaled, unnamed event.
|
||||||
@@ -162,51 +236,15 @@ impl DsWinPad {
|
|||||||
/// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives
|
/// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives
|
||||||
/// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
|
/// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
|
||||||
fn open(index: u8) -> Result<DsWinPad> {
|
fn open(index: u8) -> Result<DsWinPad> {
|
||||||
let name = HSTRING::from(format!("Global\\pfds-shm-{index}"));
|
let shm = super::gamepad_raii::Shm::create(
|
||||||
|
&HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)),
|
||||||
// A permissive DACL so the WUDFHost (whatever account it runs as) can open the section.
|
SHM_SIZE,
|
||||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
|
||||||
// SAFETY: the SDDL literal is valid; psd receives an allocated descriptor (freed by the OS
|
|
||||||
// when the process exits — acceptable for a host-lifetime object).
|
|
||||||
unsafe {
|
|
||||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
|
||||||
w!("D:(A;;GA;;;WD)"),
|
|
||||||
SDDL_REVISION_1,
|
|
||||||
&mut psd,
|
|
||||||
None,
|
|
||||||
)?;
|
)?;
|
||||||
}
|
let base = shm.base();
|
||||||
let sa = SECURITY_ATTRIBUTES {
|
// Stamp the neutral input report, then the magic LAST (the driver only accepts the section
|
||||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
// once magic is set). The device-type stays 0 (DualSense — the section is already zeroed).
|
||||||
lpSecurityDescriptor: psd.0,
|
|
||||||
bInheritHandle: false.into(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above.
|
|
||||||
let map = unsafe {
|
|
||||||
CreateFileMappingW(
|
|
||||||
INVALID_HANDLE_VALUE,
|
|
||||||
Some(&sa),
|
|
||||||
PAGE_READWRITE,
|
|
||||||
0,
|
|
||||||
SHM_SIZE as u32,
|
|
||||||
PCWSTR(name.as_ptr()),
|
|
||||||
)?
|
|
||||||
};
|
|
||||||
// SAFETY: map is a valid section handle; map the whole thing.
|
|
||||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) };
|
|
||||||
if view.Value.is_null() {
|
|
||||||
// SAFETY: map is valid.
|
|
||||||
unsafe {
|
|
||||||
let _ = CloseHandle(map);
|
|
||||||
}
|
|
||||||
return Err(anyhow!("MapViewOfFile failed for {name}"));
|
|
||||||
}
|
|
||||||
let base = view.Value as *mut u8;
|
|
||||||
// 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.
|
// 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_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);
|
||||||
@@ -217,17 +255,24 @@ 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).
|
||||||
let hsw = match create_swdevice(index) {
|
let inst = format!("pf_pad_{index}");
|
||||||
|
let hsw = match create_swdevice(&SwDeviceProfile {
|
||||||
|
instance: &inst,
|
||||||
|
container_index: index,
|
||||||
|
hwid: "pf_dualsense",
|
||||||
|
usb_vid_pid: "VID_054C&PID_0CE6",
|
||||||
|
description: "punktfunk Virtual DualSense",
|
||||||
|
}) {
|
||||||
Ok(h) => Some(h),
|
Ok(h) => Some(h),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_dualsense devnode");
|
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_dualsense devnode");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||||
Ok(DsWinPad {
|
Ok(DsWinPad {
|
||||||
hsw,
|
_sw,
|
||||||
map,
|
shm,
|
||||||
view: base,
|
|
||||||
seq: 0,
|
seq: 0,
|
||||||
ts: 0,
|
ts: 0,
|
||||||
last_out_seq: 0,
|
last_out_seq: 0,
|
||||||
@@ -240,22 +285,25 @@ impl DsWinPad {
|
|||||||
self.ts = self.ts.wrapping_add(1);
|
self.ts = self.ts.wrapping_add(1);
|
||||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||||
serialize_state(&mut r, st, self.seq, self.ts);
|
serialize_state(&mut r, st, self.seq, self.ts);
|
||||||
// SAFETY: view 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 { std::ptr::copy_nonoverlapping(r.as_ptr(), self.view.add(OFF_INPUT), r.len()) };
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.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 new.
|
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything new.
|
||||||
fn service(&mut self, pad: u8) -> DsFeedback {
|
fn service(&mut self, pad: u8) -> DsFeedback {
|
||||||
let mut fb = DsFeedback::default();
|
let mut fb = DsFeedback::default();
|
||||||
// SAFETY: view points at SHM_SIZE bytes.
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) };
|
let seq =
|
||||||
|
unsafe { std::ptr::read_unaligned(self.shm.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.view.add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||||
};
|
};
|
||||||
parse_ds_output(pad, &out, &mut fb);
|
parse_ds_output(pad, &out, &mut fb);
|
||||||
}
|
}
|
||||||
@@ -263,21 +311,6 @@ impl DsWinPad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for DsWinPad {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
|
|
||||||
unsafe {
|
|
||||||
if let Some(h) = self.hsw {
|
|
||||||
SwDeviceClose(h);
|
|
||||||
}
|
|
||||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
|
||||||
Value: self.view as *mut c_void,
|
|
||||||
});
|
|
||||||
let _ = CloseHandle(self.map);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All virtual DualSense pads of a session — the Windows analogue of
|
/// All virtual DualSense pads of a session — the Windows analogue of
|
||||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input
|
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input
|
||||||
/// thread drives either backend identically.
|
/// thread drives either backend identically.
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
//! 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
|
||||||
|
//! `Global\pfds-shm-<idx>` shared section the driver maps), same controller model ([`DsState`]); only
|
||||||
|
//! the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the report codec
|
||||||
|
//! ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4) into the
|
||||||
|
//! section so the one UMDF driver serves the DS4 descriptor / attributes / features instead of the
|
||||||
|
//! DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar (0xCD `Led`); a DS4
|
||||||
|
//! has no adaptive triggers / player LEDs.
|
||||||
|
|
||||||
|
use super::dualsense_proto::DsState;
|
||||||
|
use super::dualsense_windows::{
|
||||||
|
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_INPUT, OFF_OUTPUT,
|
||||||
|
OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE,
|
||||||
|
};
|
||||||
|
use super::dualshock4_proto::{
|
||||||
|
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
|
||||||
|
};
|
||||||
|
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||||
|
use anyhow::Result;
|
||||||
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use windows::core::HSTRING;
|
||||||
|
|
||||||
|
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the mapped
|
||||||
|
/// shared section. Dropping it removes the devnode and unmaps + closes the section.
|
||||||
|
struct Ds4WinPad {
|
||||||
|
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||||
|
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||||
|
/// The named shared section the driver maps (RAII — unmapped + closed on drop).
|
||||||
|
shm: super::gamepad_raii::Shm,
|
||||||
|
counter: u8,
|
||||||
|
ts: u16,
|
||||||
|
last_out_seq: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ds4WinPad {
|
||||||
|
/// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic,
|
||||||
|
/// then spawn the `pf_ds4_<index>` devnode (the driver loads on it and maps the section).
|
||||||
|
fn open(index: u8) -> Result<Ds4WinPad> {
|
||||||
|
let shm = super::gamepad_raii::Shm::create(
|
||||||
|
&HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)),
|
||||||
|
SHM_SIZE,
|
||||||
|
)?;
|
||||||
|
let base = shm.base();
|
||||||
|
// device-type FIRST (so it's visible the moment magic is), neutral report, magic LAST.
|
||||||
|
// SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range.
|
||||||
|
unsafe {
|
||||||
|
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
|
||||||
|
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||||
|
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
||||||
|
r
|
||||||
|
});
|
||||||
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
|
}
|
||||||
|
let inst = format!("pf_ds4_{index}");
|
||||||
|
let hsw = match create_swdevice(&SwDeviceProfile {
|
||||||
|
instance: &inst,
|
||||||
|
container_index: index,
|
||||||
|
hwid: "pf_dualshock4",
|
||||||
|
usb_vid_pid: "VID_054C&PID_09CC",
|
||||||
|
description: "punktfunk Virtual DualShock 4",
|
||||||
|
}) {
|
||||||
|
Ok(h) => Some(h),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||||
|
Ok(Ds4WinPad {
|
||||||
|
_sw,
|
||||||
|
shm,
|
||||||
|
counter: 0,
|
||||||
|
ts: 0,
|
||||||
|
last_out_seq: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
|
||||||
|
fn write_state(&mut self, st: &DsState) {
|
||||||
|
self.counter = self.counter.wrapping_add(1);
|
||||||
|
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
|
||||||
|
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||||
|
serialize_state(&mut r, st, self.counter, self.ts);
|
||||||
|
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn service(&mut self) -> Ds4Feedback {
|
||||||
|
let mut fb = Ds4Feedback::default();
|
||||||
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
|
let seq =
|
||||||
|
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
|
||||||
|
if seq != self.last_out_seq {
|
||||||
|
self.last_out_seq = seq;
|
||||||
|
let mut out = [0u8; 64];
|
||||||
|
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||||
|
};
|
||||||
|
parse_ds4_output(&out, &mut fb);
|
||||||
|
}
|
||||||
|
fb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All virtual DualShock 4 pads of a session — the Windows analogue of
|
||||||
|
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the
|
||||||
|
/// Windows DualSense manager so the session input thread drives either backend identically.
|
||||||
|
pub struct DualShock4WindowsManager {
|
||||||
|
pads: Vec<Option<Ds4WinPad>>,
|
||||||
|
state: Vec<DsState>,
|
||||||
|
last_rumble: Vec<(u16, u16)>,
|
||||||
|
last_led: Vec<Option<(u8, u8, u8)>>,
|
||||||
|
last_write: Vec<Instant>,
|
||||||
|
broken: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DualShock4WindowsManager {
|
||||||
|
fn default() -> DualShock4WindowsManager {
|
||||||
|
DualShock4WindowsManager::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DualShock4WindowsManager {
|
||||||
|
pub fn new() -> DualShock4WindowsManager {
|
||||||
|
DualShock4WindowsManager {
|
||||||
|
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||||
|
state: vec![DsState::neutral(); MAX_PADS],
|
||||||
|
last_rumble: vec![(0, 0); MAX_PADS],
|
||||||
|
last_led: vec![None; MAX_PADS],
|
||||||
|
last_write: vec![Instant::now(); MAX_PADS],
|
||||||
|
broken: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||||
|
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||||
|
match ev {
|
||||||
|
GamepadEvent::Arrival { index, kind, .. } => {
|
||||||
|
tracing::info!(index, kind, "controller arrival (DualShock 4/Windows)");
|
||||||
|
self.ensure(*index as usize);
|
||||||
|
}
|
||||||
|
GamepadEvent::State(f) => {
|
||||||
|
let idx = f.index as usize;
|
||||||
|
if idx >= MAX_PADS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||||
|
if slot.is_some() && f.active_mask & (1 << i) == 0 {
|
||||||
|
tracing::info!(index = i, "controller unplugged (DualShock 4/Windows)");
|
||||||
|
*slot = None;
|
||||||
|
self.state[i] = DsState::neutral();
|
||||||
|
self.last_rumble[i] = (0, 0);
|
||||||
|
self.last_led[i] = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.active_mask & (1 << idx) == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.ensure(idx);
|
||||||
|
let prev = self.state[idx];
|
||||||
|
let mut s = DsState::from_gamepad(
|
||||||
|
f.buttons,
|
||||||
|
f.ls_x,
|
||||||
|
f.ls_y,
|
||||||
|
f.rs_x,
|
||||||
|
f.rs_y,
|
||||||
|
f.left_trigger,
|
||||||
|
f.right_trigger,
|
||||||
|
);
|
||||||
|
s.touch = prev.touch;
|
||||||
|
s.gyro = prev.gyro;
|
||||||
|
s.accel = prev.accel;
|
||||||
|
self.state[idx] = s;
|
||||||
|
self.write(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
|
||||||
|
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||||
|
let idx = match rich {
|
||||||
|
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
|
||||||
|
};
|
||||||
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match rich {
|
||||||
|
RichInput::Touchpad {
|
||||||
|
finger,
|
||||||
|
active,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let slot = (finger as usize).min(1);
|
||||||
|
let t = &mut self.state[idx].touch[slot];
|
||||||
|
t.active = active;
|
||||||
|
t.id = slot as u8;
|
||||||
|
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||||
|
t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||||
|
}
|
||||||
|
RichInput::Motion { gyro, accel, .. } => {
|
||||||
|
self.state[idx].gyro = gyro;
|
||||||
|
self.state[idx].accel = accel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.write(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&mut self, idx: usize) {
|
||||||
|
let st = self.state[idx];
|
||||||
|
if let Some(pad) = self.pads[idx].as_mut() {
|
||||||
|
pad.write_state(&st);
|
||||||
|
}
|
||||||
|
self.last_write[idx] = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-emit each live pad's current report if it's been silent for `max_gap` (parity with the
|
||||||
|
/// other backends' heartbeat — keeps the section fresh).
|
||||||
|
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||||
|
let now = Instant::now();
|
||||||
|
for i in 0..self.pads.len() {
|
||||||
|
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
||||||
|
self.write(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure(&mut self, idx: usize) {
|
||||||
|
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match Ds4WinPad::open(idx as u8) {
|
||||||
|
Ok(p) => {
|
||||||
|
tracing::info!(
|
||||||
|
index = idx,
|
||||||
|
"virtual DualShock 4 created (Windows UMDF shm channel)"
|
||||||
|
);
|
||||||
|
self.pads[idx] = Some(p);
|
||||||
|
self.state[idx] = DsState::neutral();
|
||||||
|
self.last_rumble[idx] = (0, 0);
|
||||||
|
self.last_led[idx] = None;
|
||||||
|
self.last_write[idx] = Instant::now();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled");
|
||||||
|
self.broken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)`
|
||||||
|
/// only on change (universal 0xCA plane); `hidout` fires the lightbar (0xCD `Led`), deduped.
|
||||||
|
pub fn pump(
|
||||||
|
&mut self,
|
||||||
|
mut rumble: impl FnMut(u16, u16, u16),
|
||||||
|
mut hidout: impl FnMut(HidOutput),
|
||||||
|
) {
|
||||||
|
for i in 0..self.pads.len() {
|
||||||
|
let Some(pad) = self.pads[i].as_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let fb = pad.service();
|
||||||
|
if let Some(r) = fb.rumble {
|
||||||
|
if self.last_rumble[i] != r {
|
||||||
|
self.last_rumble[i] = r;
|
||||||
|
rumble(i as u16, r.0, r.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(rgb) = fb.led {
|
||||||
|
if self.last_led[i] != Some(rgb) {
|
||||||
|
self.last_led[i] = Some(rgb);
|
||||||
|
hidout(HidOutput::Led {
|
||||||
|
pad: i as u8,
|
||||||
|
r: rgb.0,
|
||||||
|
g: rgb.1,
|
||||||
|
b: rgb.2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! Per-pad Windows resource RAII for the gamepad backends (DualSense / DualShock 4 / XUSB).
|
||||||
|
//!
|
||||||
|
//! Each virtual pad owns two OS resources: the named shared-memory section (+ its mapped view) the
|
||||||
|
//! `pf_dualsense`/`pf_xusb` driver reads, and the `SwDeviceCreate`'d software devnode the driver loads
|
||||||
|
//! on. Before this module, all three backends hand-rolled the same `CreateFileMappingW` +
|
||||||
|
//! `MapViewOfFile` and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` + `CloseHandle` —
|
||||||
|
//! easy to drift or leak on an error path. [`Shm`] and [`SwDevice`] own those resources with RAII, so a
|
||||||
|
//! backend just holds them and the cleanup (and ordering) happens by construction.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use std::os::windows::io::{FromRawHandle, OwnedHandle};
|
||||||
|
use windows::core::{w, HSTRING, PCWSTR};
|
||||||
|
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||||
|
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||||
|
use windows::Win32::Security::Authorization::{
|
||||||
|
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||||
|
};
|
||||||
|
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||||
|
use windows::Win32::System::Memory::{
|
||||||
|
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||||
|
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view, created with the
|
||||||
|
/// permissive `D:(A;;GA;;;WD)` SDDL the restricted-token driver needs to open it. RAII: drop unmaps the
|
||||||
|
/// view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three backends'
|
||||||
|
/// hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`.
|
||||||
|
pub(super) struct Shm {
|
||||||
|
/// Owns the section handle (closed on drop). Held only for ownership — never read after construction.
|
||||||
|
_handle: OwnedHandle,
|
||||||
|
view: MEMORY_MAPPED_VIEW_ADDRESS,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shm {
|
||||||
|
/// Create + zero a `size`-byte section named `name`, mapped read/write. The section handle is owned
|
||||||
|
/// immediately, so any failure below (or the returned `Shm`'s drop) closes it.
|
||||||
|
pub(super) fn create(name: &HSTRING, size: usize) -> Result<Shm> {
|
||||||
|
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||||
|
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (freed at process
|
||||||
|
// exit — acceptable for a host-lifetime object).
|
||||||
|
unsafe {
|
||||||
|
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
|
w!("D:(A;;GA;;;WD)"),
|
||||||
|
SDDL_REVISION_1,
|
||||||
|
&mut psd,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let sa = SECURITY_ATTRIBUTES {
|
||||||
|
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
|
lpSecurityDescriptor: psd.0,
|
||||||
|
bInheritHandle: false.into(),
|
||||||
|
};
|
||||||
|
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the SDDL above.
|
||||||
|
let map = unsafe {
|
||||||
|
CreateFileMappingW(
|
||||||
|
INVALID_HANDLE_VALUE,
|
||||||
|
Some(&sa),
|
||||||
|
PAGE_READWRITE,
|
||||||
|
0,
|
||||||
|
size as u32,
|
||||||
|
PCWSTR(name.as_ptr()),
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
|
||||||
|
// return below (and the eventual drop) closes it. `map` (a `Copy` `HANDLE`) stays usable for the
|
||||||
|
// `MapViewOfFile` borrow that follows — `from_raw_handle` only copies the inner pointer.
|
||||||
|
let handle = unsafe { OwnedHandle::from_raw_handle(map.0) };
|
||||||
|
// SAFETY: `map` is a valid section handle; map the whole thing read/write.
|
||||||
|
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
|
||||||
|
if view.Value.is_null() {
|
||||||
|
// `handle` drops here → closes the section. No view to unmap.
|
||||||
|
return Err(anyhow!("MapViewOfFile failed for {name}"));
|
||||||
|
}
|
||||||
|
// SAFETY: `view` points at `size` writable bytes (just mapped).
|
||||||
|
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
|
||||||
|
Ok(Shm {
|
||||||
|
_handle: handle,
|
||||||
|
view,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
|
||||||
|
/// relocate the OS mapping — the view address is fixed by `MapViewOfFile`).
|
||||||
|
pub(super) fn base(&self) -> *mut u8 {
|
||||||
|
self.view.Value as *mut u8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Shm {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `_handle` field closes the
|
||||||
|
// section (struct fields drop only after this `Drop::drop` returns).
|
||||||
|
unsafe {
|
||||||
|
let _ = UnmapViewOfFile(self.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
|
||||||
|
/// `SwDeviceClose` each backend used to call in its `Drop`.
|
||||||
|
pub(super) struct SwDevice(HSWDEVICE);
|
||||||
|
|
||||||
|
impl SwDevice {
|
||||||
|
pub(super) fn new(hsw: HSWDEVICE) -> Self {
|
||||||
|
SwDevice(hsw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SwDevice {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.0` is the handle `SwDeviceCreate` returned; `SwDeviceClose` removes the devnode.
|
||||||
|
unsafe { SwDeviceClose(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
//! 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
|
||||||
|
//! 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
|
||||||
|
//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into the shared section
|
||||||
|
//! `Global\pfxusb-shm-<index>`. 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
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! 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 crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
|
||||||
|
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||||
|
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||||
|
};
|
||||||
|
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||||
|
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
|
||||||
|
|
||||||
|
// Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset
|
||||||
|
// asserts pin every field; the `pf_xusb` driver maps the same struct). Derive the size/offsets/magic from
|
||||||
|
// it so a layout change is a compile error, not a hand-synced literal (audit §6.1).
|
||||||
|
use pf_driver_proto::gamepad::XusbShm;
|
||||||
|
const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
|
||||||
|
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
|
||||||
|
const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
|
||||||
|
const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
|
||||||
|
const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
|
||||||
|
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: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
|
||||||
|
|
||||||
|
/// Context for the `SwDeviceCreate` completion callback: an event to signal + the HRESULT it reports.
|
||||||
|
#[repr(C)]
|
||||||
|
struct SwCreateCtx {
|
||||||
|
event: HANDLE,
|
||||||
|
result: HRESULT,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result + wake the creator.
|
||||||
|
unsafe extern "system" fn sw_create_cb(
|
||||||
|
_dev: HSWDEVICE,
|
||||||
|
result: HRESULT,
|
||||||
|
ctx: *const c_void,
|
||||||
|
_id: PCWSTR,
|
||||||
|
) {
|
||||||
|
if !ctx.is_null() {
|
||||||
|
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback.
|
||||||
|
unsafe {
|
||||||
|
let c = ctx as *mut SwCreateCtx;
|
||||||
|
(*c).result = result;
|
||||||
|
let _ = SetEvent((*c).event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the `pf_xusb_<index>` companion devnode (hardware id `pf_xusb`, enumerator `punktfunk`). The
|
||||||
|
/// INF (System class) binds our UMDF driver, which registers the XUSB interface. Unlike the HID pads,
|
||||||
|
/// no USB compatible-ids are needed — XInput finds the device by the interface GUID, not VID/PID — but
|
||||||
|
/// we still pass a deterministic non-null `pContainerId` (the null-sentinel trips an `xinput1_4`
|
||||||
|
/// slot-skip bug). `SwDeviceClose` removes it on drop.
|
||||||
|
fn create_swdevice(index: u8) -> Result<HSWDEVICE> {
|
||||||
|
let hwids: Vec<u16> = "pf_xusb".encode_utf16().chain([0u16, 0u16]).collect();
|
||||||
|
let instid: Vec<u16> = format!("pf_xusb_{index}")
|
||||||
|
.encode_utf16()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let desc: Vec<u16> = "punktfunk Virtual Xbox 360 (XUSB)"
|
||||||
|
.encode_utf16()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
// The pad index, stamped into the device Location — the driver reads it to map `pfxusb-shm-<index>`
|
||||||
|
// (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event).
|
||||||
|
let loc: Vec<u16> = format!("{index}")
|
||||||
|
.encode_utf16()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let container = GUID::from_values(0x5046_5855, 0x0000, 0x0000, [0, 0, 0, 0, 0, 0, 0, index]);
|
||||||
|
|
||||||
|
// SAFETY: zeroed then the fields we use are set; the buffers + container outlive the call.
|
||||||
|
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
|
||||||
|
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
|
||||||
|
info.pszInstanceId = PCWSTR(instid.as_ptr());
|
||||||
|
info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
|
||||||
|
info.pContainerId = &container;
|
||||||
|
info.pszDeviceDescription = PCWSTR(desc.as_ptr());
|
||||||
|
info.pszDeviceLocation = PCWSTR(loc.as_ptr());
|
||||||
|
info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable
|
||||||
|
|
||||||
|
// SAFETY: a manual-reset, initially-unsignaled, unnamed event.
|
||||||
|
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
|
||||||
|
let mut ctx = SwCreateCtx {
|
||||||
|
event,
|
||||||
|
result: HRESULT(0),
|
||||||
|
};
|
||||||
|
// SAFETY: info + buffers + ctx outlive the call (we wait on the event before returning).
|
||||||
|
let hsw = match unsafe {
|
||||||
|
SwDeviceCreate(
|
||||||
|
w!("punktfunk"),
|
||||||
|
w!("HTREE\\ROOT\\0"),
|
||||||
|
&info,
|
||||||
|
None,
|
||||||
|
Some(sw_create_cb),
|
||||||
|
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||||
|
)
|
||||||
|
} {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) => {
|
||||||
|
// SAFETY: event is valid.
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(event);
|
||||||
|
}
|
||||||
|
return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// SAFETY: event valid; block until PnP finishes enumerating, then check the callback result.
|
||||||
|
unsafe {
|
||||||
|
WaitForSingleObject(event, 10_000);
|
||||||
|
let _ = CloseHandle(event);
|
||||||
|
}
|
||||||
|
if ctx.result.is_err() {
|
||||||
|
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||||
|
unsafe { SwDeviceClose(hsw) };
|
||||||
|
return Err(anyhow!(
|
||||||
|
"SwDeviceCreate(pf_xusb) enumeration failed: {:?}",
|
||||||
|
ctx.result
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(hsw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the mapped shared section.
|
||||||
|
struct XusbWinPad {
|
||||||
|
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
|
||||||
|
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||||
|
/// Owns `Global\pfxusb-shm-<index>` (the section + its mapped view; drop unmaps + closes).
|
||||||
|
shm: super::gamepad_raii::Shm,
|
||||||
|
packet: u32,
|
||||||
|
last_rumble_seq: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XusbWinPad {
|
||||||
|
/// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode.
|
||||||
|
fn open(index: u8) -> Result<XusbWinPad> {
|
||||||
|
// Permissive-DACL named section the WUDFHost (whatever account) can open; `Shm` owns the
|
||||||
|
// section handle + its mapped view (zero-filled) and unmaps/closes on drop.
|
||||||
|
let shm = super::gamepad_raii::Shm::create(
|
||||||
|
&HSTRING::from(pf_driver_proto::gamepad::xusb_shm_name(index)),
|
||||||
|
SHM_SIZE,
|
||||||
|
)?;
|
||||||
|
let base = shm.base();
|
||||||
|
// 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.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::write_bytes(base, 0, SHM_SIZE);
|
||||||
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
|
}
|
||||||
|
let hsw = match create_swdevice(index) {
|
||||||
|
Ok(h) => Some(h),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; XUSB devnode unavailable");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||||
|
Ok(XusbWinPad {
|
||||||
|
_sw,
|
||||||
|
shm,
|
||||||
|
packet: 0,
|
||||||
|
last_rumble_seq: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the XInput state to the section and bump the packet number (XInput uses it to detect
|
||||||
|
/// change). `buttons` is the XINPUT_GAMEPAD_* bitmap; sticks are i16, triggers u8.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
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);
|
||||||
|
let base = self.shm.base();
|
||||||
|
// 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
|
||||||
|
// writes. Single owner (`&mut self`), so no concurrent writer races these stores.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
|
||||||
|
*base.add(OFF_LT) = lt;
|
||||||
|
*base.add(OFF_RT) = rt;
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_LX) as *mut i16, lx);
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly);
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx);
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_RY) as *mut i16, ry);
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_PACKET) as *mut u32, self.packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn service(&mut self) -> Option<(u8, u8)> {
|
||||||
|
let base = self.shm.base();
|
||||||
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
|
let seq = unsafe { std::ptr::read_unaligned(base.add(OFF_RUMBLE_SEQ) as *const u32) };
|
||||||
|
if seq == self.last_rumble_seq {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.last_rumble_seq = seq;
|
||||||
|
// SAFETY: rumble bytes at OFF_RUMBLE / OFF_RUMBLE+1.
|
||||||
|
let (large, small) = unsafe { (*base.add(OFF_RUMBLE), *base.add(OFF_RUMBLE + 1)) };
|
||||||
|
Some((large, small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager,
|
||||||
|
/// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the
|
||||||
|
/// session input thread already drives.
|
||||||
|
pub struct GamepadManager {
|
||||||
|
pads: Vec<Option<XusbWinPad>>,
|
||||||
|
last_rumble: Vec<(u8, u8)>,
|
||||||
|
broken: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GamepadManager {
|
||||||
|
fn default() -> GamepadManager {
|
||||||
|
GamepadManager::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GamepadManager {
|
||||||
|
pub fn new() -> GamepadManager {
|
||||||
|
GamepadManager {
|
||||||
|
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||||
|
last_rumble: vec![(0, 0); MAX_PADS],
|
||||||
|
broken: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure(&mut self, idx: usize) {
|
||||||
|
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match XusbWinPad::open(idx as u8) {
|
||||||
|
Ok(p) => {
|
||||||
|
tracing::info!(
|
||||||
|
index = idx,
|
||||||
|
"virtual Xbox 360 created (Windows XUSB companion)"
|
||||||
|
);
|
||||||
|
self.pads[idx] = Some(p);
|
||||||
|
self.last_rumble[idx] = (0, 0);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — controller input disabled (is the pf_xusb driver installed?)");
|
||||||
|
self.broken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||||
|
let GamepadEvent::State(f) = ev else {
|
||||||
|
return; // Arrival metadata — the pad is created lazily on the first State
|
||||||
|
};
|
||||||
|
let idx = f.index.max(0) as usize;
|
||||||
|
if idx >= MAX_PADS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||||
|
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||||
|
if slot.is_some() && f.active_mask & (1 << i) == 0 {
|
||||||
|
tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)");
|
||||||
|
*slot = None;
|
||||||
|
self.last_rumble[i] = (0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.active_mask & (1 << idx) == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.ensure(idx);
|
||||||
|
if let Some(pad) = self.pads[idx].as_mut() {
|
||||||
|
pad.write_state(
|
||||||
|
(f.buttons & 0xffff) as u16,
|
||||||
|
f.left_trigger,
|
||||||
|
f.right_trigger,
|
||||||
|
f.ls_x,
|
||||||
|
f.ls_y,
|
||||||
|
f.rs_x,
|
||||||
|
f.rs_y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
|
||||||
|
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
|
||||||
|
/// (high-frequency) → `high` — matching the other backends.
|
||||||
|
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||||
|
for i in 0..self.pads.len() {
|
||||||
|
let Some(pad) = self.pads[i].as_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some((large, small)) = pad.service() {
|
||||||
|
if self.last_rumble[i] != (large, small) {
|
||||||
|
self.last_rumble[i] = (large, small);
|
||||||
|
send(i as u16, large as u16 * 257, small as u16 * 257);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-2
@@ -5,6 +5,9 @@
|
|||||||
//! thread stays bound to its desktop and only reattaches (`OpenInputDesktop`/`SetThreadDesktop`) when
|
//! thread stays bound to its desktop and only reattaches (`OpenInputDesktop`/`SetThreadDesktop`) when
|
||||||
//! `SendInput` reports a short write (the input desktop switched) — no per-event reattach overhead.
|
//! `SendInput` reports a short write (the input desktop switched) — no per-event reattach overhead.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use std::mem::size_of;
|
use std::mem::size_of;
|
||||||
@@ -35,7 +38,12 @@ pub struct SendInputInjector {
|
|||||||
desktop: Option<HDESK>,
|
desktop: Option<HDESK>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only ever used from the host's single injector thread (like SudoVdaDisplay).
|
// SAFETY: `SendInputInjector` holds only an `Option<HDESK>` (a desktop handle). The host creates
|
||||||
|
// and drives it from a single dedicated injector thread; the handle is opened, rebound, and closed
|
||||||
|
// on whichever thread owns the value, and the type is not `Sync`, so there is never concurrent
|
||||||
|
// access. A desktop `HDESK` is not thread-affine for ownership (`CloseDesktop` works from any
|
||||||
|
// thread; `SetThreadDesktop` rebinds the current thread), so transferring ownership via `Send` is
|
||||||
|
// sound.
|
||||||
unsafe impl Send for SendInputInjector {}
|
unsafe impl Send for SendInputInjector {}
|
||||||
|
|
||||||
impl SendInputInjector {
|
impl SendInputInjector {
|
||||||
@@ -49,6 +57,12 @@ impl SendInputInjector {
|
|||||||
/// Bind this thread to the desktop currently receiving input. UAC / lock screen / Ctrl-Alt-Del
|
/// Bind this thread to the desktop currently receiving input. UAC / lock screen / Ctrl-Alt-Del
|
||||||
/// swap the input desktop; `SendInput` silently no-ops unless our thread is on it.
|
/// swap the input desktop; `SendInput` silently no-ops unless our thread is on it.
|
||||||
fn reattach_input_desktop(&mut self) {
|
fn reattach_input_desktop(&mut self) {
|
||||||
|
// SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` are FFI calls passed only
|
||||||
|
// by-value args (constant desktop flags, a `bool`, an access mask). `OpenInputDesktop`
|
||||||
|
// yields an owned `HDESK` only on `Ok`; we then either install it with `SetThreadDesktop`
|
||||||
|
// (closing the previously-owned handle exactly once) or close the fresh handle on failure —
|
||||||
|
// so every handle is closed exactly once and none is used after close. `SetThreadDesktop`
|
||||||
|
// only rebinds this calling thread, which is where the injector runs.
|
||||||
unsafe {
|
unsafe {
|
||||||
match OpenInputDesktop(
|
match OpenInputDesktop(
|
||||||
DESKTOP_CONTROL_FLAGS(0),
|
DESKTOP_CONTROL_FLAGS(0),
|
||||||
@@ -75,12 +89,17 @@ impl SendInputInjector {
|
|||||||
/// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop
|
/// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop
|
||||||
/// and retry once. This serves both the normal and secure desktops with no steady-state overhead.
|
/// and retry once. This serves both the normal and secure desktops with no steady-state overhead.
|
||||||
fn send(&mut self, inputs: &[INPUT]) -> Result<()> {
|
fn send(&mut self, inputs: &[INPUT]) -> Result<()> {
|
||||||
|
// SAFETY: `inputs` is a live `&[INPUT]` slice that outlives this synchronous `SendInput`
|
||||||
|
// call; `size_of::<INPUT>()` is the exact per-element stride Win32 requires as `cbSize`. The
|
||||||
|
// call only reads the array (one event per element) and returns the count injected.
|
||||||
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
||||||
if n as usize == inputs.len() {
|
if n as usize == inputs.len() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Short write → the input desktop likely changed. Reattach + retry once.
|
// Short write → the input desktop likely changed. Reattach + retry once.
|
||||||
self.reattach_input_desktop();
|
self.reattach_input_desktop();
|
||||||
|
// SAFETY: same as the first `SendInput` — `inputs` is the identical live slice outliving the
|
||||||
|
// call and `cbSize == size_of::<INPUT>()`; only re-issued after reattaching the input desktop.
|
||||||
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
||||||
if n as usize != inputs.len() {
|
if n as usize != inputs.len() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@@ -95,6 +114,9 @@ impl SendInputInjector {
|
|||||||
impl Drop for SendInputInjector {
|
impl Drop for SendInputInjector {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(h) = self.desktop.take() {
|
if let Some(h) = self.desktop.take() {
|
||||||
|
// SAFETY: `h` is the `HDESK` this injector owned (moved out of `self.desktop`);
|
||||||
|
// `CloseDesktop` runs once here in `Drop` on that still-valid handle, with no later use —
|
||||||
|
// no double close.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = CloseDesktop(h);
|
let _ = CloseDesktop(h);
|
||||||
}
|
}
|
||||||
@@ -216,7 +238,11 @@ impl InputInjector for SendInputInjector {
|
|||||||
}
|
}
|
||||||
InputKind::KeyDown | InputKind::KeyUp => {
|
InputKind::KeyDown | InputKind::KeyUp => {
|
||||||
let down = event.kind == InputKind::KeyDown;
|
let down = event.kind == InputKind::KeyDown;
|
||||||
let vk = (event.code & 0xff) as u16; // client sends Windows VK
|
// client sends Windows VK
|
||||||
|
let vk = (event.code & 0xff) as u16;
|
||||||
|
// SAFETY: `MapVirtualKeyExW` is a pure value translation (VK → scancode); all three
|
||||||
|
// args are by-value (`u32`, the `MAPVK_VK_TO_VSC_EX` map-type constant, a `None`
|
||||||
|
// HKL). It dereferences no pointer and returns a `u32` — FFI-`unsafe` only.
|
||||||
let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, None) };
|
let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, None) };
|
||||||
if sc_ex == 0 {
|
if sc_ex == 0 {
|
||||||
return Ok(()); // unmappable -> drop
|
return Ok(()); // unmappable -> drop
|
||||||
@@ -264,6 +290,8 @@ fn key(ki: KEYBDINPUT) -> INPUT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
|
fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
|
||||||
|
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
|
||||||
|
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
|
||||||
unsafe {
|
unsafe {
|
||||||
(
|
(
|
||||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
||||||
File diff suppressed because it is too large
Load Diff
+18
@@ -13,6 +13,9 @@
|
|||||||
//! attaches none, the export yields an already-signaled sync_file (poll returns immediately) — no
|
//! attaches none, the export yields an already-signaled sync_file (poll returns immediately) — no
|
||||||
//! wait, no harm, and `waited=false` tells us the driver doesn't fence (so zero-copy would still race).
|
//! wait, no harm, and `waited=false` tells us the driver doesn't fence (so zero-copy would still race).
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use std::os::fd::RawFd;
|
use std::os::fd::RawFd;
|
||||||
|
|
||||||
// linux/dma-buf.h ioctls on the DMA_BUF_BASE ('b' = 0x62) magic. _IOWR = dir(3)<<30 | size<<16 | base<<8 | nr.
|
// linux/dma-buf.h ioctls on the DMA_BUF_BASE ('b' = 0x62) magic. _IOWR = dir(3)<<30 | size<<16 | base<<8 | nr.
|
||||||
@@ -40,6 +43,11 @@ pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<boo
|
|||||||
flags: DMA_BUF_SYNC_READ,
|
flags: DMA_BUF_SYNC_READ,
|
||||||
fd: -1,
|
fd: -1,
|
||||||
};
|
};
|
||||||
|
// SAFETY: `dmabuf_fd` is a live dmabuf fd supplied by the caller (borrowed for this call; we
|
||||||
|
// never close it). `DMA_BUF_IOCTL_EXPORT_SYNC_FILE` encodes `size_of::<DmaBufExportSyncFile>()`
|
||||||
|
// — the exact byte count the kernel copies — and `&mut req` is a live, correctly-sized
|
||||||
|
// `#[repr(C)]` struct the EXPORT_SYNC_FILE ioctl reads (`flags`) and writes (`fd`). `req`
|
||||||
|
// outlives this synchronous call and is not aliased elsewhere.
|
||||||
let r = unsafe { libc::ioctl(dmabuf_fd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &mut req) };
|
let r = unsafe { libc::ioctl(dmabuf_fd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &mut req) };
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
return Err(std::io::Error::last_os_error());
|
return Err(std::io::Error::last_os_error());
|
||||||
@@ -54,11 +62,21 @@ pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<boo
|
|||||||
revents: 0,
|
revents: 0,
|
||||||
};
|
};
|
||||||
// Non-blocking probe: not-yet-signaled (poll==0) means the producer is still rendering.
|
// Non-blocking probe: not-yet-signaled (poll==0) means the producer is still rendering.
|
||||||
|
// SAFETY: `&mut pfd` points at a single live `libc::pollfd` and `nfds == 1` matches that one
|
||||||
|
// element; `pfd.fd` is `sync_fd`, the sync_file fd just exported (already checked `>= 0`).
|
||||||
|
// `poll` reads `fd`/`events` and writes `revents` for this non-blocking (timeout 0) probe, then
|
||||||
|
// returns — `pfd` outlives the call and aliases nothing.
|
||||||
let pending = unsafe { libc::poll(&mut pfd, 1, 0) } == 0;
|
let pending = unsafe { libc::poll(&mut pfd, 1, 0) } == 0;
|
||||||
if pending {
|
if pending {
|
||||||
pfd.revents = 0;
|
pfd.revents = 0;
|
||||||
|
// SAFETY: same live single-element `pfd` (its `revents` reset to 0 just above), `nfds == 1`,
|
||||||
|
// and `sync_fd` still open. This blocking `poll` (up to `timeout_ms`) waits for the render
|
||||||
|
// fence to signal; it reads `fd`/`events`, writes `revents`, and returns before `pfd` ends.
|
||||||
unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; // block until the render fence signals
|
unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; // block until the render fence signals
|
||||||
}
|
}
|
||||||
|
// SAFETY: `sync_fd` is the sync_file fd the EXPORT_SYNC_FILE ioctl created and handed us to own;
|
||||||
|
// this point is reached only when `sync_fd >= 0`, this `close` runs exactly once on it, and it is
|
||||||
|
// never used afterward — no double-close or use-after-close.
|
||||||
unsafe { libc::close(sync_fd) };
|
unsafe { libc::close(sync_fd) };
|
||||||
Ok(pending)
|
Ok(pending)
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
//! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer
|
//! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer
|
||||||
//! gains working `SPA_META_SyncTimeline`.
|
//! gains working `SPA_META_SyncTimeline`.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
//!
|
//!
|
||||||
//! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual
|
//! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual
|
||||||
//! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf
|
//! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf
|
||||||
@@ -81,6 +83,8 @@ pub struct DrmSync {
|
|||||||
impl DrmSync {
|
impl DrmSync {
|
||||||
pub fn open() -> Result<DrmSync> {
|
pub fn open() -> Result<DrmSync> {
|
||||||
let path = c"/dev/dri/renderD128";
|
let path = c"/dev/dri/renderD128";
|
||||||
|
// SAFETY: `path` is a 'static NUL-terminated C string literal; `open` only reads it as a
|
||||||
|
// filesystem path and returns an fd (or -1). No Rust memory is aliased or handed to the kernel.
|
||||||
let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
||||||
if fd < 0 {
|
if fd < 0 {
|
||||||
bail!("open /dev/dri/renderD128 for syncobj ops: {}", errno());
|
bail!("open /dev/dri/renderD128 for syncobj ops: {}", errno());
|
||||||
@@ -94,6 +98,9 @@ impl DrmSync {
|
|||||||
fd: syncobj_fd,
|
fd: syncobj_fd,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
// SAFETY: `self.fd` is the live render-node fd from `open`; the request number encodes
|
||||||
|
// `size_of::<DrmSyncobjHandle>()` (the bytes the kernel copies), and `&mut req` is a live,
|
||||||
|
// correctly-sized `#[repr(C)]` struct the FD_TO_HANDLE ioctl reads (`fd`) and writes (`handle`).
|
||||||
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, &mut req) };
|
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, &mut req) };
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
bail!("SYNCOBJ_FD_TO_HANDLE: {}", errno());
|
bail!("SYNCOBJ_FD_TO_HANDLE: {}", errno());
|
||||||
@@ -106,6 +113,8 @@ impl DrmSync {
|
|||||||
handle,
|
handle,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
// SAFETY: `self.fd` is the live render-node fd; `DRM_IOCTL_SYNCOBJ_DESTROY` encodes
|
||||||
|
// `size_of::<DrmSyncobjDestroy>()`, and `&mut req` is a live correctly-sized struct the kernel reads.
|
||||||
unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_DESTROY, &mut req) };
|
unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_DESTROY, &mut req) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +126,8 @@ impl DrmSync {
|
|||||||
tv_sec: 0,
|
tv_sec: 0,
|
||||||
tv_nsec: 0,
|
tv_nsec: 0,
|
||||||
};
|
};
|
||||||
|
// SAFETY: `CLOCK_MONOTONIC` is a valid clock id and `&mut now` is a live `libc::timespec` the
|
||||||
|
// kernel fills in; the call returns before `now` is read, so there is no aliasing/lifetime issue.
|
||||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) };
|
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) };
|
||||||
let deadline = now.tv_sec * 1_000_000_000 + now.tv_nsec + timeout_ms as i64 * 1_000_000;
|
let deadline = now.tv_sec * 1_000_000_000 + now.tv_nsec + timeout_ms as i64 * 1_000_000;
|
||||||
let handles = [handle];
|
let handles = [handle];
|
||||||
@@ -129,6 +140,11 @@ impl DrmSync {
|
|||||||
flags: DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT,
|
flags: DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
// SAFETY: `self.fd` is the live render-node fd; the request number encodes
|
||||||
|
// `size_of::<DrmSyncobjTimelineWait>()`; `&mut req` is a live correctly-sized struct. Its
|
||||||
|
// `handles`/`points` u64 fields hold the addresses of the local `handles`/`points` arrays, which
|
||||||
|
// outlive this synchronous call, and `count_handles == 1` matches their length — so every kernel
|
||||||
|
// read through those addresses stays in bounds.
|
||||||
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, &mut req) };
|
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, &mut req) };
|
||||||
let saved = errno();
|
let saved = errno();
|
||||||
self.destroy(handle);
|
self.destroy(handle);
|
||||||
@@ -151,6 +167,10 @@ impl DrmSync {
|
|||||||
count_handles: 1,
|
count_handles: 1,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
};
|
};
|
||||||
|
// SAFETY: `self.fd` is the live render-node fd; the request number encodes
|
||||||
|
// `size_of::<DrmSyncobjTimelineArray>()`; `&mut req` is a live correctly-sized struct whose
|
||||||
|
// `handles`/`points` u64 fields address the local `handles`/`points` arrays (alive for this
|
||||||
|
// synchronous call, `count_handles == 1` matching their length).
|
||||||
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, &mut req) };
|
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, &mut req) };
|
||||||
let saved = errno();
|
let saved = errno();
|
||||||
self.destroy(handle);
|
self.destroy(handle);
|
||||||
@@ -163,6 +183,8 @@ impl DrmSync {
|
|||||||
|
|
||||||
impl Drop for DrmSync {
|
impl Drop for DrmSync {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.fd` is the fd `open` returned; this `DrmSync` owns it exclusively and `close`
|
||||||
|
// runs exactly once (here, in `Drop`), so there is no double-close or use-after-close.
|
||||||
unsafe { libc::close(self.fd) };
|
unsafe { libc::close(self.fd) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,14 +225,19 @@ mod tests {
|
|||||||
const CREATE: u64 = iowr(0xBF, std::mem::size_of::<Create>());
|
const CREATE: u64 = iowr(0xBF, std::mem::size_of::<Create>());
|
||||||
const HANDLE_TO_FD: u64 = iowr(0xC1, std::mem::size_of::<DrmSyncobjHandle>());
|
const HANDLE_TO_FD: u64 = iowr(0xC1, std::mem::size_of::<DrmSyncobjHandle>());
|
||||||
let mut c = Create::default();
|
let mut c = Create::default();
|
||||||
|
// SAFETY: `sync.fd` is the live render-node fd; `CREATE` encodes `size_of::<Create>()`, and
|
||||||
|
// `&mut c` is a live correctly-sized struct the kernel fills (`handle`).
|
||||||
assert!(unsafe { libc::ioctl(sync.fd, CREATE, &mut c) } >= 0);
|
assert!(unsafe { libc::ioctl(sync.fd, CREATE, &mut c) } >= 0);
|
||||||
let mut h = DrmSyncobjHandle {
|
let mut h = DrmSyncobjHandle {
|
||||||
handle: c.handle,
|
handle: c.handle,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
// SAFETY: `sync.fd` is live; `HANDLE_TO_FD` encodes `size_of::<DrmSyncobjHandle>()`; `&mut h`
|
||||||
|
// is a live correctly-sized struct (the kernel reads `handle`, writes `fd`).
|
||||||
assert!(unsafe { libc::ioctl(sync.fd, HANDLE_TO_FD, &mut h) } >= 0);
|
assert!(unsafe { libc::ioctl(sync.fd, HANDLE_TO_FD, &mut h) } >= 0);
|
||||||
sync.signal_point(h.fd, 1).expect("signal");
|
sync.signal_point(h.fd, 1).expect("signal");
|
||||||
sync.wait_point(h.fd, 1, 100).expect("wait after signal");
|
sync.wait_point(h.fd, 1, 100).expect("wait after signal");
|
||||||
|
// SAFETY: `h.fd` is the fd HANDLE_TO_FD just exported; we own it and close it exactly once here.
|
||||||
unsafe { libc::close(h.fd) };
|
unsafe { libc::close(h.fd) };
|
||||||
sync.destroy(c.handle);
|
sync.destroy(c.handle);
|
||||||
}
|
}
|
||||||
+138
-2
@@ -11,6 +11,8 @@
|
|||||||
//! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use.
|
//! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use.
|
||||||
|
|
||||||
#![allow(non_camel_case_types, non_snake_case)]
|
#![allow(non_camel_case_types, non_snake_case)]
|
||||||
|
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use std::os::raw::{c_int, c_uint, c_void};
|
use std::os::raw::{c_int, c_uint, c_void};
|
||||||
@@ -128,8 +130,14 @@ struct CudaApi {
|
|||||||
) -> CUresult,
|
) -> CUresult,
|
||||||
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
||||||
}
|
}
|
||||||
// The resolved fn pointers are plain addresses into a process-lifetime mapping; safe to share.
|
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
|
||||||
|
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
|
||||||
|
// value with no interior mutability and no thread affinity. Moving the table to another thread
|
||||||
|
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
|
||||||
unsafe impl Send for CudaApi {}
|
unsafe impl Send for CudaApi {}
|
||||||
|
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
|
||||||
|
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
|
||||||
|
// points they address are themselves thread-safe.
|
||||||
unsafe impl Sync for CudaApi {}
|
unsafe impl Sync for CudaApi {}
|
||||||
|
|
||||||
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
|
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
|
||||||
@@ -143,6 +151,14 @@ static CUDA_API: OnceLock<Option<CudaApi>> = OnceLock::new();
|
|||||||
/// (the expected case on AMD/Intel hosts) — logged at debug, not an error.
|
/// (the expected case on AMD/Intel hosts) — logged at debug, not an error.
|
||||||
fn cuda_api() -> Option<&'static CudaApi> {
|
fn cuda_api() -> Option<&'static CudaApi> {
|
||||||
CUDA_API
|
CUDA_API
|
||||||
|
// SAFETY: `Library::new` runs `libcuda.so.1`'s initializers — it is the trusted NVIDIA
|
||||||
|
// driver library, so loading has no unexpected effects; `?`/`None` handle its absence.
|
||||||
|
// Each `lib.get::<T>(name)` asserts the symbol's real ABI equals `T`: every NUL-terminated
|
||||||
|
// name is a documented CUDA Driver API entry point and `T` is the exact
|
||||||
|
// `unsafe extern "C" fn(..)` signature from cuda.h/cudaGL.h (`_v2` for ctx/mem ops). Each
|
||||||
|
// `Symbol` only borrows `lib` until the end of the struct-literal statement; we deref-copy
|
||||||
|
// the raw fn-pointer out first, then `forget(lib)` leaks the mapping so those addresses
|
||||||
|
// stay valid for the whole process. Runs once under the `OnceLock` init — no aliasing.
|
||||||
.get_or_init(|| unsafe {
|
.get_or_init(|| unsafe {
|
||||||
let lib = libloading::Library::new("libcuda.so.1")
|
let lib = libloading::Library::new("libcuda.so.1")
|
||||||
.or_else(|_| libloading::Library::new("libcuda.so"))
|
.or_else(|_| libloading::Library::new("libcuda.so"))
|
||||||
@@ -361,6 +377,12 @@ pub fn read_plane_to_host(
|
|||||||
Height: height,
|
Height: height,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
// SAFETY: `copy_blocking` is unsafe because it issues a CUDA copy; its contract is a valid
|
||||||
|
// descriptor with the shared context current (the caller's responsibility — self-test path).
|
||||||
|
// `©` is a live local `#[repr(C)] CUDA_MEMCPY2D` that outlives the synchronous call:
|
||||||
|
// `srcDevice`/`srcPitch` are the caller's live pitched device plane, `dstHost` addresses the
|
||||||
|
// freshly-allocated `host` `Vec` of exactly `width_bytes*height` bytes, and `WidthInBytes`×
|
||||||
|
// `Height` fit both. The copy is synchronous, so `host` is fully written before we return it.
|
||||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(dev->host)")? };
|
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(dev->host)")? };
|
||||||
Ok(host)
|
Ok(host)
|
||||||
}
|
}
|
||||||
@@ -369,7 +391,13 @@ pub fn read_plane_to_host(
|
|||||||
/// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread.
|
/// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct Context(pub CUcontext);
|
pub struct Context(pub CUcontext);
|
||||||
|
// SAFETY: `CUcontext` is an opaque CUDA driver handle, not a dereferenceable Rust pointer. It is
|
||||||
|
// created once and never destroyed (process lifetime), and the only thing done with it is
|
||||||
|
// `cuCtxSetCurrent`, which the Driver API explicitly allows from any thread — so transferring the
|
||||||
|
// handle to another thread cannot dangle or race (the driver owns the synchronization).
|
||||||
unsafe impl Send for Context {}
|
unsafe impl Send for Context {}
|
||||||
|
// SAFETY: as above — the wrapped handle is an immutable opaque address and the driver does all the
|
||||||
|
// synchronization, so sharing `&Context` across threads is sound.
|
||||||
unsafe impl Sync for Context {}
|
unsafe impl Sync for Context {}
|
||||||
|
|
||||||
static CONTEXT: OnceLock<Context> = OnceLock::new();
|
static CONTEXT: OnceLock<Context> = OnceLock::new();
|
||||||
@@ -382,6 +410,12 @@ pub fn context() -> Result<CUcontext> {
|
|||||||
if cuda_api().is_none() {
|
if cuda_api().is_none() {
|
||||||
bail!("libcuda.so.1 not available — no NVIDIA driver (CUDA zero-copy disabled)");
|
bail!("libcuda.so.1 not available — no NVIDIA driver (CUDA zero-copy disabled)");
|
||||||
}
|
}
|
||||||
|
// SAFETY: we returned above unless `cuda_api()` is `Some`, so every wrapper here forwards into
|
||||||
|
// the live, leaked `libcuda` table rather than the not-loaded stub. `cuInit(0)` passes the
|
||||||
|
// API-required flags value 0. `&mut dev`/`&mut ctx` are live, zero/null-initialized stack
|
||||||
|
// out-params the driver writes the device handle / new context into; each outlives its
|
||||||
|
// synchronous call and they are distinct locals (no aliasing). `cuCtxCreate_v2` yields a valid
|
||||||
|
// `CUcontext` on success (`ck` bails otherwise), which becomes the block's value.
|
||||||
let ctx = unsafe {
|
let ctx = unsafe {
|
||||||
ck(cuInit(0), "cuInit")?;
|
ck(cuInit(0), "cuInit")?;
|
||||||
let mut dev: CUdevice = 0;
|
let mut dev: CUdevice = 0;
|
||||||
@@ -401,6 +435,10 @@ pub fn context() -> Result<CUcontext> {
|
|||||||
/// Make the shared context current on the calling thread (required before any CUDA op here).
|
/// Make the shared context current on the calling thread (required before any CUDA op here).
|
||||||
pub fn make_current() -> Result<()> {
|
pub fn make_current() -> Result<()> {
|
||||||
let ctx = context()?;
|
let ctx = context()?;
|
||||||
|
// SAFETY: `ctx` came from `context()?`, so it is the live shared `CUcontext` and the driver
|
||||||
|
// table is present. `cuCtxSetCurrent` binds that opaque handle to the calling thread; it takes
|
||||||
|
// no Rust-memory pointer and is thread-safe (affects only this thread's current context), so
|
||||||
|
// there is no aliasing or lifetime hazard.
|
||||||
unsafe { ck(cuCtxSetCurrent(ctx), "cuCtxSetCurrent") }
|
unsafe { ck(cuCtxSetCurrent(ctx), "cuCtxSetCurrent") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +461,12 @@ fn copy_stream() -> CUstream {
|
|||||||
if let Some(s) = cell.get() {
|
if let Some(s) = cell.get() {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
// SAFETY: `copy_stream` runs with the shared context current (its doc contract), so the
|
||||||
|
// wrappers forward into the live `libcuda` table. `&mut least`/`&mut greatest` are live
|
||||||
|
// stack `i32`s the driver fills with the priority range; `&mut s` is a live null-init
|
||||||
|
// `CUstream` the driver writes the new stream into. All out-params outlive their
|
||||||
|
// synchronous calls and are distinct locals. On any non-zero result we fall back to a null
|
||||||
|
// (NULL-stream) value and never read an uninitialized handle.
|
||||||
let stream = unsafe {
|
let stream = unsafe {
|
||||||
let (mut least, mut greatest) = (0i32, 0i32);
|
let (mut least, mut greatest) = (0i32, 0i32);
|
||||||
if cuCtxGetStreamPriorityRange(&mut least, &mut greatest) != 0 {
|
if cuCtxGetStreamPriorityRange(&mut least, &mut greatest) != 0 {
|
||||||
@@ -459,6 +503,11 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
|
|||||||
fn alloc_pitched(width: u32, height: u32) -> Result<(CUdeviceptr, usize)> {
|
fn alloc_pitched(width: u32, height: u32) -> Result<(CUdeviceptr, usize)> {
|
||||||
let mut ptr: CUdeviceptr = 0;
|
let mut ptr: CUdeviceptr = 0;
|
||||||
let mut pitch: usize = 0;
|
let mut pitch: usize = 0;
|
||||||
|
// SAFETY: `cuMemAllocPitch_v2` allocates a pitched device buffer (the wrapper forwards to the
|
||||||
|
// live table on any path that reached allocation). `&mut ptr` (`CUdeviceptr`) and `&mut pitch`
|
||||||
|
// (`usize`) are live, distinct stack out-params the driver writes the allocation pointer and
|
||||||
|
// its pitch into; both outlive the synchronous call. Width/height/element-size are by-value
|
||||||
|
// ints. No aliasing — two separate locals.
|
||||||
unsafe {
|
unsafe {
|
||||||
ck(
|
ck(
|
||||||
cuMemAllocPitch_v2(
|
cuMemAllocPitch_v2(
|
||||||
@@ -486,6 +535,10 @@ fn alloc_pitched_nv12(
|
|||||||
let mut y_pitch: usize = 0;
|
let mut y_pitch: usize = 0;
|
||||||
let mut uv_ptr: CUdeviceptr = 0;
|
let mut uv_ptr: CUdeviceptr = 0;
|
||||||
let mut uv_pitch: usize = 0;
|
let mut uv_pitch: usize = 0;
|
||||||
|
// SAFETY: two independent `cuMemAllocPitch_v2` calls (wrapper → live table). `&mut y_ptr`/
|
||||||
|
// `&mut y_pitch` and `&mut uv_ptr`/`&mut uv_pitch` are live, distinct stack out-params the
|
||||||
|
// driver writes each plane's pointer and pitch into; all outlive their synchronous calls. The
|
||||||
|
// dimension/element-size args are by-value ints. No aliasing — four separate locals.
|
||||||
unsafe {
|
unsafe {
|
||||||
ck(
|
ck(
|
||||||
cuMemAllocPitch_v2(
|
cuMemAllocPitch_v2(
|
||||||
@@ -524,6 +577,13 @@ struct PoolInner {
|
|||||||
|
|
||||||
impl Drop for PoolInner {
|
impl Drop for PoolInner {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: the pool only exists because allocation succeeded, so the driver table is live.
|
||||||
|
// `PoolInner` drops only once every `DeviceBuffer` that referenced it (each holds an `Arc`
|
||||||
|
// clone) has been recycled, so `free`/`free_uv` hold every outstanding allocation exactly
|
||||||
|
// once and nothing else still uses them — no double-free or use-after-free. We make the
|
||||||
|
// shared context current first (drop may run off the allocating thread) so `cuMemFree_v2`
|
||||||
|
// targets the right context. Each `p` is a `CUdeviceptr` previously returned by
|
||||||
|
// `cuMemAllocPitch_v2`; results are ignored (best-effort teardown).
|
||||||
unsafe {
|
unsafe {
|
||||||
if let Some(c) = CONTEXT.get() {
|
if let Some(c) = CONTEXT.get() {
|
||||||
let _ = cuCtxSetCurrent(c.0);
|
let _ = cuCtxSetCurrent(c.0);
|
||||||
@@ -697,6 +757,12 @@ impl Drop for DeviceBuffer {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// The buffer may be freed on the encode thread; cuMemFree needs a current context.
|
// The buffer may be freed on the encode thread; cuMemFree needs a current context.
|
||||||
|
// SAFETY: this is the un-pooled branch (`pool` is `None`), so this `DeviceBuffer`
|
||||||
|
// exclusively owns `self.ptr` (and `self.uv`'s `uv_ptr`), each returned by
|
||||||
|
// `cuMemAllocPitch_v2` and freed exactly once here — `drop` runs once and the
|
||||||
|
// `self.ptr == 0` guard above skips the sentinel/empty case, so no double-free. We set
|
||||||
|
// the shared context current first because drop may run on a thread where it isn't, and
|
||||||
|
// `cuMemFree_v2` needs it. Wrapper → live table; results ignored (teardown).
|
||||||
unsafe {
|
unsafe {
|
||||||
if let Some(c) = CONTEXT.get() {
|
if let Some(c) = CONTEXT.get() {
|
||||||
let _ = cuCtxSetCurrent(c.0);
|
let _ = cuCtxSetCurrent(c.0);
|
||||||
@@ -745,6 +811,16 @@ impl RegisteredTexture {
|
|||||||
/// unmap. The copy is synchronized (on our priority stream) before unmap so `dst` is ready
|
/// unmap. The copy is synchronized (on our priority stream) before unmap so `dst` is ready
|
||||||
/// before the source dmabuf is recycled. Always unmaps, even if the copy errors.
|
/// before the source dmabuf is recycled. Always unmaps, even if the copy errors.
|
||||||
pub fn copy_mapped_to(&mut self, dst: &DeviceBuffer) -> Result<()> {
|
pub fn copy_mapped_to(&mut self, dst: &DeviceBuffer) -> Result<()> {
|
||||||
|
// SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl`
|
||||||
|
// (its only constructor), so the wrappers forward to the live table; the caller holds the
|
||||||
|
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
|
||||||
|
// `count == 1` resource via `&mut self.resource` (a live field) on the default stream;
|
||||||
|
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
|
||||||
|
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `©` is a live
|
||||||
|
// local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid
|
||||||
|
// while mapped, `dstDevice`/`dstPitch` are `dst`'s live allocation, `width*4`×`height` fit
|
||||||
|
// both. `copy_blocking` syncs before we unmap, so the array stays valid through the copy;
|
||||||
|
// we always unmap afterward (even on error), keeping the map/unmap pair balanced.
|
||||||
unsafe {
|
unsafe {
|
||||||
ck(
|
ck(
|
||||||
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||||
@@ -783,6 +859,14 @@ impl RegisteredTexture {
|
|||||||
width_bytes: usize,
|
width_bytes: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
// SAFETY: identical contract to `copy_mapped_to` — `self.resource` is the valid
|
||||||
|
// `CUgraphicsResource` from `register_gl` (wrappers → live table; caller holds GL+CUDA
|
||||||
|
// contexts current). Map `count == 1` resource via the live `&mut self.resource`; the
|
||||||
|
// mapped `CUarray` is written into the live local `array` (index 0, mip 0); on failure we
|
||||||
|
// unmap and bail (balanced). `©` is a live local outliving the synchronous
|
||||||
|
// `copy_blocking`: `srcArray` valid while mapped, `dstDevice`/`dstPitch` are the caller's
|
||||||
|
// live plane, `width_bytes`×`height` fit it. We always unmap afterward, even on copy error,
|
||||||
|
// so the map/unmap pair stays balanced and the array outlives the copy.
|
||||||
unsafe {
|
unsafe {
|
||||||
ck(
|
ck(
|
||||||
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||||
@@ -847,6 +931,10 @@ pub fn copy_device_to_device(
|
|||||||
Height: src.height as usize,
|
Height: src.height as usize,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
|
||||||
|
// context current (documented). `©` is a live local device→device `CUDA_MEMCPY2D` outliving
|
||||||
|
// the synchronous call: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/
|
||||||
|
// `dstPitch` the caller's live region, `width*4`×`height` within both. Wrapper → live table.
|
||||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(dev->dev)") }
|
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(dev->dev)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,6 +976,12 @@ pub fn copy_nv12_to_device(
|
|||||||
Height: h / 2,
|
Height: h / 2,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
// SAFETY: two unsafe `copy_blocking` device→device copies; the caller must have the shared
|
||||||
|
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
|
||||||
|
// synchronous call. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
|
||||||
|
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
|
||||||
|
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
|
||||||
|
// `(w/2)*2`×`h/2`, each within its planes. Wrappers → live table.
|
||||||
unsafe {
|
unsafe {
|
||||||
copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
|
copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
|
||||||
copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")
|
copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")
|
||||||
@@ -897,6 +991,12 @@ pub fn copy_nv12_to_device(
|
|||||||
impl Drop for RegisteredTexture {
|
impl Drop for RegisteredTexture {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if !self.resource.is_null() {
|
if !self.resource.is_null() {
|
||||||
|
// SAFETY: `self.resource` is non-null (just checked) and is the valid
|
||||||
|
// `CUgraphicsResource` from `register_gl`, owned exclusively by this `RegisteredTexture`
|
||||||
|
// and unregistered exactly once here (drop runs once) — no use-after-free or
|
||||||
|
// double-unregister. `cuGraphicsUnregisterResource` releases the GL↔CUDA registration;
|
||||||
|
// wrapper → live table (the resource exists ⇒ the driver was present). Result ignored
|
||||||
|
// (best-effort teardown).
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = cuGraphicsUnregisterResource(self.resource);
|
let _ = cuGraphicsUnregisterResource(self.resource);
|
||||||
}
|
}
|
||||||
@@ -913,7 +1013,11 @@ pub struct ExternalDmabuf {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw driver handles; used from the single capture thread but moved with the importer.
|
// SAFETY: the fields are opaque CUDA driver handles — an external-memory handle and a device
|
||||||
|
// pointer — not dereferenceable Rust memory, and the value is uniquely owned (no `Clone`). It is
|
||||||
|
// used from a single capture thread but constructed on / moved between threads with the importer;
|
||||||
|
// transferring these handles is sound because uniqueness rules out aliasing and they are destroyed
|
||||||
|
// exactly once in `Drop`. Only `Send` (not `Sync`) is asserted, matching the single-thread use.
|
||||||
unsafe impl Send for ExternalDmabuf {}
|
unsafe impl Send for ExternalDmabuf {}
|
||||||
|
|
||||||
impl ExternalDmabuf {
|
impl ExternalDmabuf {
|
||||||
@@ -921,6 +1025,9 @@ impl ExternalDmabuf {
|
|||||||
/// from then on) and map its full `size` bytes to a device pointer. The shared context
|
/// from then on) and map its full `size` bytes to a device pointer. The shared context
|
||||||
/// must be current.
|
/// must be current.
|
||||||
pub fn import(fd: i32, size: u64) -> Result<ExternalDmabuf> {
|
pub fn import(fd: i32, size: u64) -> Result<ExternalDmabuf> {
|
||||||
|
// SAFETY: `libc::dup` only reads the integer `fd` and returns a new descriptor (or -1); it
|
||||||
|
// touches no Rust memory and `fd` is the caller's still-owned dmabuf fd (not consumed
|
||||||
|
// here). No aliasing or lifetime concern — a pure syscall on an integer.
|
||||||
let dup = unsafe { libc::dup(fd) };
|
let dup = unsafe { libc::dup(fd) };
|
||||||
if dup < 0 {
|
if dup < 0 {
|
||||||
bail!("dup(dmabuf fd) failed");
|
bail!("dup(dmabuf fd) failed");
|
||||||
@@ -938,8 +1045,17 @@ impl ExternalDmabuf {
|
|||||||
};
|
};
|
||||||
desc.handle[0] = dup as u32 as u64; // union member `int fd` (little-endian low bytes)
|
desc.handle[0] = dup as u32 as u64; // union member `int fd` (little-endian low bytes)
|
||||||
let mut ext: CUexternalMemory = std::ptr::null_mut();
|
let mut ext: CUexternalMemory = std::ptr::null_mut();
|
||||||
|
// SAFETY: `cuImportExternalMemory` imports the memory described by `&desc`, a live local
|
||||||
|
// `#[repr(C)] CUDA_EXTERNAL_MEMORY_HANDLE_DESC` (cuda.h 64-bit layout) that outlives this
|
||||||
|
// synchronous call: `type_` is OPAQUE_FD, `handle[0]` holds the dup'd fd in the union's
|
||||||
|
// `int fd` low bytes, `size` is set. `&mut ext` is a live null-init out-param the driver
|
||||||
|
// writes the imported handle into. The driver takes ownership of the fd only on success.
|
||||||
|
// Distinct locals → no aliasing. Wrapper → live table (caller holds the context current).
|
||||||
let r = unsafe { cuImportExternalMemory(&mut ext, &desc) };
|
let r = unsafe { cuImportExternalMemory(&mut ext, &desc) };
|
||||||
if r != 0 {
|
if r != 0 {
|
||||||
|
// SAFETY: import failed (`r != 0`), so the driver did NOT take ownership of `dup`; we
|
||||||
|
// still own it and close it exactly once here on the error path (the success path never
|
||||||
|
// closes it — the driver does). `libc::close` acts on the integer fd alone.
|
||||||
unsafe { libc::close(dup) }; // import failed → the driver did not take the fd
|
unsafe { libc::close(dup) }; // import failed → the driver did not take the fd
|
||||||
bail!("cuImportExternalMemory failed ({r}) — LINEAR dmabuf import unsupported?");
|
bail!("cuImportExternalMemory failed ({r}) — LINEAR dmabuf import unsupported?");
|
||||||
}
|
}
|
||||||
@@ -949,8 +1065,17 @@ impl ExternalDmabuf {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut ptr: CUdeviceptr = 0;
|
let mut ptr: CUdeviceptr = 0;
|
||||||
|
// SAFETY: maps a device pointer from `ext` (the valid `CUexternalMemory` just imported) per
|
||||||
|
// `&buf`, a live local `CUDA_EXTERNAL_MEMORY_BUFFER_DESC` (offset 0, full `size`) that
|
||||||
|
// outlives this synchronous call. `&mut ptr` is a live zero-init out-param the driver writes
|
||||||
|
// the mapped device address into; distinct locals → no aliasing. Wrapper → live table
|
||||||
|
// (context current).
|
||||||
let r = unsafe { cuExternalMemoryGetMappedBuffer(&mut ptr, ext, &buf) };
|
let r = unsafe { cuExternalMemoryGetMappedBuffer(&mut ptr, ext, &buf) };
|
||||||
if r != 0 {
|
if r != 0 {
|
||||||
|
// SAFETY: mapping failed; `ext` is the valid `CUexternalMemory` we imported and
|
||||||
|
// exclusively own. We destroy it exactly once here on the error path (the success path
|
||||||
|
// instead moves it into the returned `ExternalDmabuf`, whose `Drop` destroys it),
|
||||||
|
// releasing the fd the driver took — no double-destroy or use-after-free.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = cuDestroyExternalMemory(ext);
|
let _ = cuDestroyExternalMemory(ext);
|
||||||
}
|
}
|
||||||
@@ -962,6 +1087,12 @@ impl ExternalDmabuf {
|
|||||||
|
|
||||||
impl Drop for ExternalDmabuf {
|
impl Drop for ExternalDmabuf {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: this `ExternalDmabuf` only exists after a successful import, so the driver table
|
||||||
|
// is live. It exclusively owns `self.ptr` (the mapped buffer) and `self.ext` (the external
|
||||||
|
// memory), each torn down exactly once here (drop runs once; guarded by `!= 0` / `!null`) —
|
||||||
|
// no double-free or use-after-free. We make the shared context current first because drop
|
||||||
|
// may run off the import thread, and we free the mapped buffer before destroying its
|
||||||
|
// backing external memory. Results ignored (best-effort teardown).
|
||||||
unsafe {
|
unsafe {
|
||||||
if let Some(c) = CONTEXT.get() {
|
if let Some(c) = CONTEXT.get() {
|
||||||
let _ = cuCtxSetCurrent(c.0);
|
let _ = cuCtxSetCurrent(c.0);
|
||||||
@@ -996,5 +1127,10 @@ pub fn copy_pitched_to_buffer(
|
|||||||
};
|
};
|
||||||
// copy_blocking syncs our priority stream before returning, so the copy is complete before the
|
// copy_blocking syncs our priority stream before returning, so the copy is complete before the
|
||||||
// dmabuf is requeued to the producer.
|
// dmabuf is requeued to the producer.
|
||||||
|
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
|
||||||
|
// context current (documented). `©` is a live local device→device `CUDA_MEMCPY2D` outliving
|
||||||
|
// the synchronous call: `srcDevice`/`srcPitch` are the caller's live mapped span (e.g. an
|
||||||
|
// `ExternalDmabuf`), `dstDevice`/`dstPitch` are `dst`'s live allocation, `width*4`×`height`
|
||||||
|
// within both. Wrapper → live table.
|
||||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(ext->dev)") }
|
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(ext->dev)") }
|
||||||
}
|
}
|
||||||
+110
-3
@@ -12,6 +12,8 @@
|
|||||||
//! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately.
|
//! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately.
|
||||||
|
|
||||||
#![allow(non_upper_case_globals)]
|
#![allow(non_upper_case_globals)]
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::cuda::{self, DeviceBuffer};
|
use super::cuda::{self, DeviceBuffer};
|
||||||
use anyhow::{bail, ensure, Context as _, Result};
|
use anyhow::{bail, ensure, Context as _, Result};
|
||||||
@@ -415,6 +417,14 @@ impl Nv12Blit {
|
|||||||
|
|
||||||
impl Drop for Nv12Blit {
|
impl Drop for Nv12Blit {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: these GL names (textures/FBOs/VAO/programs) were all created by THIS `Nv12Blit`
|
||||||
|
// in `Nv12Blit::new` on the current GL context, which is still current because the owning
|
||||||
|
// `EglImporter` is dropped on its single capture thread (fields drop before
|
||||||
|
// `EglImporter::drop`, which never releases the context). `glDelete*` takes a count + a
|
||||||
|
// pointer to that many names: `&self.y_tex`/`&self.vao` are `&u32` to one live field (n=1);
|
||||||
|
// `[self.y_fbo, self.uv_fbo].as_ptr()` points at a 2-element temporary that lives for the
|
||||||
|
// whole `glDeleteFramebuffers` call (n=2 matches). The symbols dispatch through libGL
|
||||||
|
// (libglvnd) to the driver for the current context. Each name is deleted exactly once.
|
||||||
unsafe {
|
unsafe {
|
||||||
glDeleteTextures(1, &self.y_tex);
|
glDeleteTextures(1, &self.y_tex);
|
||||||
glDeleteTextures(1, &self.uv_tex);
|
glDeleteTextures(1, &self.uv_tex);
|
||||||
@@ -459,7 +469,14 @@ pub struct EglImporter {
|
|||||||
render_fd: c_int,
|
render_fd: c_int,
|
||||||
}
|
}
|
||||||
|
|
||||||
// The EGL handles are confined to the capture thread; the struct is moved there once.
|
// SAFETY: `EglImporter` owns thread-affine handles — an EGLDisplay/contexts made current on one
|
||||||
|
// thread, a loaded GL proc pointer, a `gbm_device*`, a raw fd, and CUDA-registered GL textures —
|
||||||
|
// none safe to touch concurrently. It is constructed inside `pipewire_thread` on the dedicated
|
||||||
|
// `punktfunk-pipewire` thread, and every method (`import*`, `supported_modifiers`, `Drop`) runs on
|
||||||
|
// that same thread; it is never accessed through a shared `&` from another thread. `Send` asserts
|
||||||
|
// only that transferring *ownership* is sound (needed so the importer can live in the PipeWire
|
||||||
|
// stream's user-data, whose API imposes a `Send` bound) — the live handles are never used
|
||||||
|
// off-thread. `Sync` is deliberately NOT implied.
|
||||||
unsafe impl Send for EglImporter {}
|
unsafe impl Send for EglImporter {}
|
||||||
|
|
||||||
impl EglImporter {
|
impl EglImporter {
|
||||||
@@ -470,16 +487,38 @@ impl EglImporter {
|
|||||||
// to the same DRM device CUDA-GL interop associates with, which the EGL device platform
|
// to the same DRM device CUDA-GL interop associates with, which the EGL device platform
|
||||||
// did not (cuGraphicsGLRegisterImage rejected device-platform GL textures).
|
// did not (cuGraphicsGLRegisterImage rejected device-platform GL textures).
|
||||||
let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap();
|
let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap();
|
||||||
|
// SAFETY: `path` is a live local `CString` (built from a string with no interior NUL, so it
|
||||||
|
// is NUL-terminated); `path.as_ptr()` is a valid pointer to that buffer which outlives this
|
||||||
|
// synchronous `open`. `open` only reads the path and returns a new fd (or -1); it neither
|
||||||
|
// retains the pointer nor writes through it, so there is no aliasing or lifetime hazard.
|
||||||
let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
||||||
ensure!(render_fd >= 0, "open /dev/dri/renderD128 for GBM");
|
ensure!(render_fd >= 0, "open /dev/dri/renderD128 for GBM");
|
||||||
|
// SAFETY: `render_fd` is the live DRM render-node fd just returned by `open` and checked
|
||||||
|
// `>= 0`. `gbm_create_device` (libgbm, linked above) builds a `gbm_device` over that fd and
|
||||||
|
// returns a `*mut gbm_device` (or null); it borrows but does not take ownership of the fd,
|
||||||
|
// which `EglImporter` keeps open and closes only in `Drop` after `gbm_device_destroy`. No
|
||||||
|
// Rust-owned memory is passed, so there is nothing to alias.
|
||||||
let gbm = unsafe { gbm_create_device(render_fd) };
|
let gbm = unsafe { gbm_create_device(render_fd) };
|
||||||
if gbm.is_null() {
|
if gbm.is_null() {
|
||||||
|
// SAFETY: reached only when `gbm_create_device` failed (null) — the fd was not consumed
|
||||||
|
// and no `EglImporter` exists yet to close it again, so this `close` runs exactly once on
|
||||||
|
// the live `render_fd`, releasing it before the error return. No double-close.
|
||||||
unsafe { libc::close(render_fd) };
|
unsafe { libc::close(render_fd) };
|
||||||
anyhow::bail!("gbm_create_device failed");
|
anyhow::bail!("gbm_create_device failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAFETY: `Egl::load_required` dlopens the system libEGL and binds its entry points,
|
||||||
|
// trusting that libEGL (libglvnd) is a genuine EGL 1.5 implementation whose core symbols
|
||||||
|
// match the ABI the `khronos_egl` `EGL1_5` bindings declare. No Rust memory is passed; the
|
||||||
|
// returned instance is afterwards used only through the safe `khronos_egl` wrappers.
|
||||||
let egl: Egl =
|
let egl: Egl =
|
||||||
unsafe { Egl::load_required() }.context("load libEGL (EGL 1.5 dynamic instance)")?;
|
unsafe { Egl::load_required() }.context("load libEGL (EGL 1.5 dynamic instance)")?;
|
||||||
|
// SAFETY: `gbm` is the non-null `gbm_device*` created just above (checked), and
|
||||||
|
// `EGL_PLATFORM_GBM_KHR` is exactly the platform enum that pairs with a GBM device as the
|
||||||
|
// native-display handle, so the `gbm as NativeDisplayType` cast hands EGL a valid native
|
||||||
|
// display for the requested platform. `&[egl::ATTRIB_NONE]` is a properly terminated, empty
|
||||||
|
// attribute array borrowed for this synchronous call; EGL only reads it and returns an
|
||||||
|
// `EGLDisplay`, retaining no pointer into Rust memory.
|
||||||
let display = unsafe {
|
let display = unsafe {
|
||||||
egl.get_platform_display(
|
egl.get_platform_display(
|
||||||
EGL_PLATFORM_GBM_KHR,
|
EGL_PLATFORM_GBM_KHR,
|
||||||
@@ -533,6 +572,13 @@ impl EglImporter {
|
|||||||
.context("eglCreateContext(OpenGL)")?;
|
.context("eglCreateContext(OpenGL)")?;
|
||||||
egl.make_current(display, None, None, Some(gl_ctx))
|
egl.make_current(display, None, None, Some(gl_ctx))
|
||||||
.context("eglMakeCurrent surfaceless (needs EGL_KHR_surfaceless_context)")?;
|
.context("eglMakeCurrent surfaceless (needs EGL_KHR_surfaceless_context)")?;
|
||||||
|
// SAFETY: the GL context was made current on this thread just above, which `eglGetProcAddress`
|
||||||
|
// requires to return a usable pointer. The non-null (`?`-checked) pointer it returns for
|
||||||
|
// "glEGLImageTargetTexture2DOES" is the driver's implementation of that GL-OES entry point,
|
||||||
|
// whose real ABI is `void(GLenum, GLeglImageOES)` = `(u32, *mut c_void)` `extern "system"`.
|
||||||
|
// `EglImageTargetFn` is declared with exactly that signature, so the transmute only retypes a
|
||||||
|
// same-size, same-ABI thin function pointer (no value/representation change). The function is
|
||||||
|
// present because `EGL_EXT_image_dma_buf_import` was asserted on this display above.
|
||||||
let egl_image_target: EglImageTargetFn = unsafe {
|
let egl_image_target: EglImageTargetFn = unsafe {
|
||||||
std::mem::transmute(
|
std::mem::transmute(
|
||||||
egl.get_proc_address("glEGLImageTargetTexture2DOES")
|
egl.get_proc_address("glEGLImageTargetTexture2DOES")
|
||||||
@@ -543,6 +589,10 @@ impl EglImporter {
|
|||||||
// Create the shared CUDA context up front so import() is pure hot path.
|
// Create the shared CUDA context up front so import() is pure hot path.
|
||||||
cuda::context().context("create CUDA context")?;
|
cuda::context().context("create CUDA context")?;
|
||||||
|
|
||||||
|
// SAFETY: `egl::NO_CONTEXT` is EGL's defined sentinel (a null handle) for "no context";
|
||||||
|
// `Context::from_ptr` only stores the handle (it never dereferences it), so wrapping the
|
||||||
|
// null sentinel is sound and yields exactly the `EGL_NO_CONTEXT` value that
|
||||||
|
// `eglCreateImage(EGL_LINUX_DMA_BUF_EXT)` requires as its context argument later.
|
||||||
let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) };
|
let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) };
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)"
|
"zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)"
|
||||||
@@ -602,8 +652,21 @@ impl EglImporter {
|
|||||||
let Some(sym) = self.egl.get_proc_address("eglQueryDmaBufModifiersEXT") else {
|
let Some(sym) = self.egl.get_proc_address("eglQueryDmaBufModifiersEXT") else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
// SAFETY: `sym` is the non-null pointer `eglGetProcAddress("eglQueryDmaBufModifiersEXT")`
|
||||||
|
// returned (the `let-else` already bailed on `None`) — the driver's implementation of that
|
||||||
|
// EGL extension entry point. `QueryFn` is declared with that function's exact documented ABI
|
||||||
|
// (`EGLDisplay, EGLint, EGLint, EGLuint64* , EGLBoolean*, EGLint* -> EGLBoolean`), all
|
||||||
|
// `extern "system"`, so the transmute only retypes a same-size, same-ABI thin fn pointer.
|
||||||
let query: QueryFn = unsafe { std::mem::transmute(sym) };
|
let query: QueryFn = unsafe { std::mem::transmute(sym) };
|
||||||
let dpy = self.display.as_ptr();
|
let dpy = self.display.as_ptr();
|
||||||
|
// SAFETY: `dpy` is this importer's live, initialized `EGLDisplay`; `query` is the proc loaded
|
||||||
|
// just above. The first call passes null out-arrays with `max_modifiers == 0`, which the
|
||||||
|
// extension defines as "write only the count" — it writes solely through `&mut count` (a live
|
||||||
|
// local `i32`). For the second call, `mods`/`ext` are freshly allocated `Vec`s of exactly
|
||||||
|
// `count` elements and `max_modifiers == count`, so the driver writes at most `count`
|
||||||
|
// `u64`/`u32` entries (in bounds) plus the actual count through `&mut n` (a live local). All
|
||||||
|
// four Rust addresses outlive these synchronous calls and alias nothing else. `truncate` only
|
||||||
|
// shrinks, so even a misbehaving `n > count` cannot read out of bounds.
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut count: i32 = 0;
|
let mut count: i32 = 0;
|
||||||
if query(
|
if query(
|
||||||
@@ -699,6 +762,10 @@ impl EglImporter {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
attrs.push(egl::ATTRIB_NONE);
|
attrs.push(egl::ATTRIB_NONE);
|
||||||
|
// SAFETY: `eglCreateImage(EGL_LINUX_DMA_BUF_EXT, ...)` mandates a NULL `EGLClientBuffer`
|
||||||
|
// (the source is described entirely by the attribute list built above), so wrapping
|
||||||
|
// `null_mut()` is the required value. `from_ptr` only stores the pointer without
|
||||||
|
// dereferencing it, so constructing it from null is sound.
|
||||||
let client = unsafe { egl::ClientBuffer::from_ptr(std::ptr::null_mut()) };
|
let client = unsafe { egl::ClientBuffer::from_ptr(std::ptr::null_mut()) };
|
||||||
let image = self
|
let image = self
|
||||||
.egl
|
.egl
|
||||||
@@ -733,11 +800,21 @@ impl EglImporter {
|
|||||||
) -> Result<DeviceBuffer> {
|
) -> Result<DeviceBuffer> {
|
||||||
cuda::make_current()?;
|
cuda::make_current()?;
|
||||||
if self.blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
|
if self.blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
|
||||||
|
// SAFETY: `GlBlit::new` requires the GL context current on the calling thread and a
|
||||||
|
// current CUDA context. Both hold: this runs on the capture thread where
|
||||||
|
// `EglImporter::new` made the GL context current and never released it, and
|
||||||
|
// `cuda::make_current()?` ran at the top of this function. `width`/`height` are plain
|
||||||
|
// `Copy` frame dimensions.
|
||||||
self.blit = Some(unsafe { GlBlit::new(width, height)? });
|
self.blit = Some(unsafe { GlBlit::new(width, height)? });
|
||||||
}
|
}
|
||||||
let egl_image_target = self.egl_image_target;
|
let egl_image_target = self.egl_image_target;
|
||||||
let blit = self.blit.as_mut().unwrap();
|
let blit = self.blit.as_mut().unwrap();
|
||||||
// SAFETY: GL + CUDA contexts current on this thread; `image` is a valid EGLImage.
|
// SAFETY: `GlBlit::run` requires a current GL context and a valid `EGLImage`. The GL context
|
||||||
|
// is current on this capture thread (made current in `EglImporter::new`, never released) and
|
||||||
|
// `cuda::make_current()` ran above; `egl_image_target` is the `glEGLImageTargetTexture2DOES`
|
||||||
|
// pointer loaded in `new`; `image` is the raw handle of the live `EGLImage` that
|
||||||
|
// `import_inner` created with `eglCreateImage` and destroys only AFTER this call returns, so
|
||||||
|
// it stays valid for the whole synchronous `run`.
|
||||||
unsafe { blit.run(egl_image_target, image)? };
|
unsafe { blit.run(egl_image_target, image)? };
|
||||||
// Persistent registration (mapped per frame) + a pooled buffer — no per-frame
|
// Persistent registration (mapped per frame) + a pooled buffer — no per-frame
|
||||||
// cuGraphicsGLRegisterImage / cuMemAllocPitch.
|
// cuGraphicsGLRegisterImage / cuMemAllocPitch.
|
||||||
@@ -757,11 +834,21 @@ impl EglImporter {
|
|||||||
) -> Result<DeviceBuffer> {
|
) -> Result<DeviceBuffer> {
|
||||||
cuda::make_current()?;
|
cuda::make_current()?;
|
||||||
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
|
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
|
||||||
|
// SAFETY: `Nv12Blit::new` requires the GL context current on the calling thread and a
|
||||||
|
// current CUDA context. Both hold: this runs on the capture thread where
|
||||||
|
// `EglImporter::new` made the GL context current and never released it, and
|
||||||
|
// `cuda::make_current()?` ran at the top of this function. `width`/`height` are plain
|
||||||
|
// `Copy` frame dimensions.
|
||||||
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
|
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
|
||||||
}
|
}
|
||||||
let egl_image_target = self.egl_image_target;
|
let egl_image_target = self.egl_image_target;
|
||||||
let blit = self.nv12_blit.as_mut().unwrap();
|
let blit = self.nv12_blit.as_mut().unwrap();
|
||||||
// SAFETY: GL + CUDA contexts current on this thread; `image` is a valid EGLImage.
|
// SAFETY: `Nv12Blit::run` requires a current GL context and a valid `EGLImage`. The GL
|
||||||
|
// context is current on this capture thread (made current in `EglImporter::new`, never
|
||||||
|
// released) and `cuda::make_current()` ran above; `egl_image_target` is the
|
||||||
|
// `glEGLImageTargetTexture2DOES` pointer loaded in `new`; `image` is the raw handle of the
|
||||||
|
// live `EGLImage` that `import_inner` created with `eglCreateImage` and destroys only AFTER
|
||||||
|
// this call returns, so it stays valid for the whole synchronous `run`.
|
||||||
unsafe { blit.run(egl_image_target, image)? };
|
unsafe { blit.run(egl_image_target, image)? };
|
||||||
let dst = blit.pool.get()?;
|
let dst = blit.pool.get()?;
|
||||||
cuda::copy_mapped_nv12(&mut blit.y_registered, &mut blit.uv_registered, &dst)?;
|
cuda::copy_mapped_nv12(&mut blit.y_registered, &mut blit.uv_registered, &dst)?;
|
||||||
@@ -787,9 +874,22 @@ impl EglImporter {
|
|||||||
);
|
);
|
||||||
cuda::make_current()?;
|
cuda::make_current()?;
|
||||||
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
|
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
|
||||||
|
// SAFETY: `Nv12Blit::new` requires the GL context current on the calling thread and a
|
||||||
|
// current CUDA context. Both hold: this self-test path runs on the thread that owns this
|
||||||
|
// `EglImporter` with its GL context current, and `cuda::make_current()?` ran just above.
|
||||||
|
// `width`/`height` are plain `Copy` scalars.
|
||||||
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
|
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
|
||||||
}
|
}
|
||||||
let blit = self.nv12_blit.as_mut().unwrap();
|
let blit = self.nv12_blit.as_mut().unwrap();
|
||||||
|
// SAFETY: runs on the thread that owns this `EglImporter` with its GL context current.
|
||||||
|
// `blit.src_tex` is a texture this `Nv12Blit` owns; `glTexStorage2D` allocates immutable
|
||||||
|
// RGBA8 storage exactly once (guarded by `test_src_storage`) sized `width×height`.
|
||||||
|
// `glTexSubImage2D` then uploads exactly `width×height` RGBA8 texels, reading `width*height*4`
|
||||||
|
// bytes from `rgba.as_ptr()`; the caller already asserted `rgba.len() == width*height*4`, rows
|
||||||
|
// are `width*4` bytes (a multiple of the default 4-byte unpack alignment, so no row-padding
|
||||||
|
// over-read), and `rgba` is a live borrow that outlives this synchronous upload. `run_passes`
|
||||||
|
// then needs only the current GL context (no further Rust pointers). All GL names are this
|
||||||
|
// blit's own, alias no other live object, and nothing is retained past the calls.
|
||||||
unsafe {
|
unsafe {
|
||||||
// Upload the host RGBA into `src_tex` (an immutable GL_RGBA8 backing must exist first;
|
// Upload the host RGBA into `src_tex` (an immutable GL_RGBA8 backing must exist first;
|
||||||
// the live path never allocates it — it retargets `src_tex` via EGLImage instead).
|
// the live path never allocates it — it retargets `src_tex` via EGLImage instead).
|
||||||
@@ -824,9 +924,16 @@ impl EglImporter {
|
|||||||
impl Drop for EglImporter {
|
impl Drop for EglImporter {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if !self.gbm.is_null() {
|
if !self.gbm.is_null() {
|
||||||
|
// SAFETY: `self.gbm` is the non-null `gbm_device*` from `gbm_create_device` in `new`
|
||||||
|
// (checked non-null here), owned exclusively by this `EglImporter` and destroyed exactly
|
||||||
|
// once (in `Drop`). It is freed BEFORE `render_fd` is closed below — the correct order,
|
||||||
|
// since the device borrowed that fd for its lifetime.
|
||||||
unsafe { gbm_device_destroy(self.gbm) };
|
unsafe { gbm_device_destroy(self.gbm) };
|
||||||
}
|
}
|
||||||
if self.render_fd >= 0 {
|
if self.render_fd >= 0 {
|
||||||
|
// SAFETY: `self.render_fd` is the fd `open` returned in `new` (checked `>= 0`), owned
|
||||||
|
// exclusively by this `EglImporter`; this `close` runs exactly once, after the gbm device
|
||||||
|
// that borrowed it has been destroyed. No double-close or use-after-close.
|
||||||
unsafe { libc::close(self.render_fd) };
|
unsafe { libc::close(self.render_fd) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+41
-1
@@ -16,6 +16,9 @@
|
|||||||
//! a stream's life). Falls back cleanly: any init/import error disables the importer and the
|
//! a stream's life). Falls back cleanly: any init/import error disables the importer and the
|
||||||
//! CPU mmap path takes over.
|
//! CPU mmap path takes over.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::cuda::{self, DeviceBuffer};
|
use super::cuda::{self, DeviceBuffer};
|
||||||
use anyhow::{anyhow, bail, Context as _, Result};
|
use anyhow::{anyhow, bail, Context as _, Result};
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
@@ -51,12 +54,27 @@ pub struct VkBridge {
|
|||||||
dst: Option<DstBuf>,
|
dst: Option<DstBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confined to the capture thread; moved there once.
|
// SAFETY: `VkBridge` owns ash Vulkan handles (instance/device/queue/command pool+buffer/fence), a
|
||||||
|
// CUDA external-memory mapping, and an fd→buffer cache — none `Sync`, and a single queue +
|
||||||
|
// command buffer must be externally synchronized. It is created inside `EglImporter::import_linear`
|
||||||
|
// on the dedicated `punktfunk-pipewire` capture thread and every method (`import_linear`, `Drop`)
|
||||||
|
// runs on that thread; it is never shared via `&` across threads. `Send` asserts only that
|
||||||
|
// transferring ownership is sound (so the bridge can live inside the `Send` `EglImporter`); the live
|
||||||
|
// handles are never touched off-thread, and `Sync` is deliberately NOT implied.
|
||||||
unsafe impl Send for VkBridge {}
|
unsafe impl Send for VkBridge {}
|
||||||
|
|
||||||
impl VkBridge {
|
impl VkBridge {
|
||||||
/// Bring up Vulkan on the NVIDIA GPU with the external-memory extensions.
|
/// Bring up Vulkan on the NVIDIA GPU with the external-memory extensions.
|
||||||
pub fn new() -> Result<VkBridge> {
|
pub fn new() -> Result<VkBridge> {
|
||||||
|
// SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically
|
||||||
|
// verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system
|
||||||
|
// libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`,
|
||||||
|
// `exts`, `prio`, `qci`, and the inline infos) that all live for the duration of the
|
||||||
|
// synchronous `create_*`/`enumerate_*` call that reads them — in particular the
|
||||||
|
// `enabled_extension_names(&exts)` and `queue_priorities(&prio)` borrows outlive their calls.
|
||||||
|
// Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and
|
||||||
|
// checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This
|
||||||
|
// constructor shares nothing across threads.
|
||||||
unsafe {
|
unsafe {
|
||||||
let entry = ash::Entry::load().context("load libvulkan")?;
|
let entry = ash::Entry::load().context("load libvulkan")?;
|
||||||
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_1);
|
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_1);
|
||||||
@@ -294,6 +312,19 @@ impl VkBridge {
|
|||||||
height: u32,
|
height: u32,
|
||||||
pool: &cuda::BufferPool,
|
pool: &cuda::BufferPool,
|
||||||
) -> Result<DeviceBuffer> {
|
) -> Result<DeviceBuffer> {
|
||||||
|
// SAFETY: `fd` is the live dmabuf fd handed in by the caller (borrowed; `import_src` dup's it
|
||||||
|
// internally and Vulkan owns the dup). `libc::lseek` only queries the fd's size. The unsafe
|
||||||
|
// `import_src`/`ensure_dst` are called with a valid fd and a checked size. The bounds are
|
||||||
|
// proven: `import_src` asserts `size >= span` (so the cached `src_size >= span`),
|
||||||
|
// `copy_size = src_size.min(span)`, and `ensure_dst(copy_size)` makes `dst` at least
|
||||||
|
// `copy_size` — so the GPU `cmd_copy_buffer` of `copy_size` bytes reads/writes within both
|
||||||
|
// buffers, and the later CUDA pitched copy reading `[offset, span)` from `dst.cuda.ptr` (=
|
||||||
|
// `offset + stride*height = span <= copy_size`) stays inside the freshly-copied region. The
|
||||||
|
// `*Info`/`region`/`cmds`/`submit` are locals that outlive the synchronous calls reading them.
|
||||||
|
// `cmd`/`queue`/`fence` are this bridge's own handles, used on this single thread only. The
|
||||||
|
// host-side `wait_for_fences` fully retires the Vulkan copy BEFORE CUDA reads the shared
|
||||||
|
// memory, so there is no GPU write/read data race. `dst` is an `&self.dst` shared borrow that
|
||||||
|
// does not alias the `&self.device` calls.
|
||||||
unsafe {
|
unsafe {
|
||||||
let span = offset as u64 + stride as u64 * height as u64;
|
let span = offset as u64 + stride as u64 * height as u64;
|
||||||
if !self.src_cache.contains_key(&fd) {
|
if !self.src_cache.contains_key(&fd) {
|
||||||
@@ -347,6 +378,15 @@ impl VkBridge {
|
|||||||
|
|
||||||
impl Drop for VkBridge {
|
impl Drop for VkBridge {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: runs once when the bridge is dropped on its owning capture thread.
|
||||||
|
// `device_wait_idle` first drains all in-flight GPU work, so no queued command still
|
||||||
|
// references these objects. Every handle freed (the `src_cache` buffers+memories, the `dst`
|
||||||
|
// buffer+memory, `fence`, `cmd_pool`, `device`, `instance`) was created by this `VkBridge`
|
||||||
|
// and owned exclusively by it, so each `destroy_*`/`free_*` runs exactly once with no
|
||||||
|
// double-free, in dependency order (child objects before `device`, `device` before
|
||||||
|
// `instance`). `dst.cuda` is dropped after `free_memory`, which is safe because CUDA holds
|
||||||
|
// its own dup'd OPAQUE_FD reference to the underlying allocation. No other thread touches
|
||||||
|
// these handles.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = self.device.device_wait_idle();
|
let _ = self.device.device_wait_idle();
|
||||||
for (_, s) in self.src_cache.drain() {
|
for (_, s) in self.src_cache.drain() {
|
||||||
@@ -13,18 +13,33 @@
|
|||||||
|
|
||||||
// Scaffold: trait methods and config paths are defined ahead of their backends.
|
// Scaffold: trait methods and config paths are defined ahead of their backends.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` in the crate must carry a `// SAFETY:`
|
||||||
|
// proof of why it is sound. This crate-root deny is the permanent, catch-all gate (it also covers
|
||||||
|
// any future module); individual files keep their own `#![deny(...)]` as belt-and-suspenders.
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
mod capture;
|
mod capture;
|
||||||
|
mod config;
|
||||||
mod discovery;
|
mod discovery;
|
||||||
|
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
||||||
|
// keeps the `crate::*` module names flat (every existing path is unchanged).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "linux/dmabuf_fence.rs"]
|
||||||
mod dmabuf_fence;
|
mod dmabuf_fence;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "linux/drm_sync.rs"]
|
||||||
mod drm_sync;
|
mod drm_sync;
|
||||||
mod encode;
|
mod encode;
|
||||||
mod gamestream;
|
mod gamestream;
|
||||||
mod hdr;
|
mod hdr;
|
||||||
mod inject;
|
mod inject;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/install.rs"]
|
||||||
|
mod install;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/interactive.rs"]
|
||||||
|
mod interactive;
|
||||||
mod library;
|
mod library;
|
||||||
mod mgmt;
|
mod mgmt;
|
||||||
mod mgmt_token;
|
mod mgmt_token;
|
||||||
@@ -33,13 +48,24 @@ mod pipeline;
|
|||||||
mod punktfunk1;
|
mod punktfunk1;
|
||||||
mod pwinit;
|
mod pwinit;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/service.rs"]
|
||||||
mod service;
|
mod service;
|
||||||
|
mod session_plan;
|
||||||
mod session_tuning;
|
mod session_tuning;
|
||||||
mod spike;
|
mod spike;
|
||||||
|
mod stats_recorder;
|
||||||
mod vdisplay;
|
mod vdisplay;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/wgc_helper.rs"]
|
||||||
mod wgc_helper;
|
mod wgc_helper;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/win_adapter.rs"]
|
||||||
|
mod win_adapter;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/win_display.rs"]
|
||||||
|
mod win_display;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "linux/zerocopy/mod.rs"]
|
||||||
mod zerocopy;
|
mod zerocopy;
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
@@ -209,7 +235,6 @@ fn real_main() -> Result<()> {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
Some("dualsense-windows-test") => {
|
Some("dualsense-windows-test") => {
|
||||||
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame};
|
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame};
|
||||||
use inject::dualsense_windows::DualSenseWindowsManager;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
let secs: u64 = args
|
let secs: u64 = args
|
||||||
.iter()
|
.iter()
|
||||||
@@ -217,24 +242,39 @@ fn real_main() -> Result<()> {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(20);
|
.unwrap_or(20);
|
||||||
let mut mgr = DualSenseWindowsManager::new();
|
// `--index N` creates pad `pf_pad_N` (default 0) — use a spare index (e.g. 1) to test
|
||||||
// Arrival creates the pad (SwDeviceCreate + section); State pushes the report.
|
// alongside a running host that already holds pad 0. `--ds4` drives the DualShock 4
|
||||||
|
// backend instead of the DualSense one.
|
||||||
|
let idx: u8 = args
|
||||||
|
.iter()
|
||||||
|
.skip_while(|a| *a != "--index")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let ds4 = args.iter().any(|a| a == "--ds4");
|
||||||
|
let xbox = args.iter().any(|a| a == "--xbox");
|
||||||
|
// Same drive loop for either backend (identical method surface): Arrival creates the pad,
|
||||||
|
// State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
|
||||||
|
macro_rules! drive {
|
||||||
|
($mgr:expr, $label:expr) => {{
|
||||||
|
let mut mgr = $mgr;
|
||||||
mgr.handle(&GamepadEvent::Arrival {
|
mgr.handle(&GamepadEvent::Arrival {
|
||||||
index: 0,
|
index: idx,
|
||||||
kind: 2,
|
kind: 2,
|
||||||
capabilities: 0,
|
capabilities: 0,
|
||||||
});
|
});
|
||||||
println!(
|
println!(
|
||||||
"virtual DualSense up — cycling Cross + sweeping the left stick for {secs}s. Watch it \
|
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
|
||||||
in joy.cpl / Steam / a game; any rumble / lightbar / trigger the game sends prints below."
|
it in joy.cpl / Steam / a game; any feedback the game sends prints below.",
|
||||||
|
$label
|
||||||
);
|
);
|
||||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||||
let (mut i, mut last) = (0i32, Instant::now());
|
let (mut i, mut last) = (0i32, Instant::now());
|
||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
// Surface a game's feedback: rumble (universal) + lightbar / player-LED / adaptive
|
|
||||||
// triggers (DualSense-only) coming back over the shared section.
|
|
||||||
mgr.pump(
|
mgr.pump(
|
||||||
|pad, lo, hi| println!(" rumble from game: pad={pad} low={lo} high={hi}"),
|
|pad, lo, hi| {
|
||||||
|
println!(" rumble from game: pad={pad} low={lo} high={hi}")
|
||||||
|
},
|
||||||
|o| println!(" hid output from game: {o:?}"),
|
|o| println!(" hid output from game: {o:?}"),
|
||||||
);
|
);
|
||||||
if last.elapsed() >= Duration::from_millis(400) {
|
if last.elapsed() >= Duration::from_millis(400) {
|
||||||
@@ -247,8 +287,8 @@ fn real_main() -> Result<()> {
|
|||||||
};
|
};
|
||||||
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
||||||
mgr.handle(&GamepadEvent::State(GamepadFrame {
|
mgr.handle(&GamepadEvent::State(GamepadFrame {
|
||||||
index: 0,
|
index: idx as i16,
|
||||||
active_mask: 1,
|
active_mask: 1 << idx,
|
||||||
buttons,
|
buttons,
|
||||||
left_trigger: 0,
|
left_trigger: 0,
|
||||||
right_trigger: 0,
|
right_trigger: 0,
|
||||||
@@ -260,6 +300,58 @@ fn real_main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(15));
|
std::thread::sleep(Duration::from_millis(15));
|
||||||
}
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
if xbox {
|
||||||
|
// Xbox 360 via the XUSB companion: a different surface (handle + pump_rumble, no
|
||||||
|
// HID-output plane), so drive it inline rather than via the macro.
|
||||||
|
let mut mgr = inject::gamepad::GamepadManager::new();
|
||||||
|
mgr.handle(&GamepadEvent::Arrival {
|
||||||
|
index: idx,
|
||||||
|
kind: 1,
|
||||||
|
capabilities: 0,
|
||||||
|
});
|
||||||
|
println!(
|
||||||
|
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
|
||||||
|
an XInput game or xinputtest.exe."
|
||||||
|
);
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||||
|
let mut t = 0i32;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
mgr.pump_rumble(|pad, lo, hi| {
|
||||||
|
println!(" rumble from game: pad={pad} low={lo} high={hi}")
|
||||||
|
});
|
||||||
|
t += 1;
|
||||||
|
let lx = (((t % 200) - 100) * 327).clamp(-32768, 32767) as i16; // sweep ±32700
|
||||||
|
let buttons = if (t / 67) % 2 == 0 {
|
||||||
|
punktfunk_core::input::gamepad::BTN_A
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
mgr.handle(&GamepadEvent::State(GamepadFrame {
|
||||||
|
index: idx as i16,
|
||||||
|
active_mask: 1 << idx,
|
||||||
|
buttons,
|
||||||
|
left_trigger: 0,
|
||||||
|
right_trigger: 0,
|
||||||
|
ls_x: lx,
|
||||||
|
ls_y: 0,
|
||||||
|
rs_x: 0,
|
||||||
|
rs_y: 0,
|
||||||
|
}));
|
||||||
|
std::thread::sleep(Duration::from_millis(15));
|
||||||
|
}
|
||||||
|
} else if ds4 {
|
||||||
|
drive!(
|
||||||
|
inject::dualshock4_windows::DualShock4WindowsManager::new(),
|
||||||
|
"DualShock 4"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
drive!(
|
||||||
|
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
||||||
|
"DualSense"
|
||||||
|
);
|
||||||
|
}
|
||||||
println!("dualsense-windows-test: done (devnode removed)");
|
println!("dualsense-windows-test: done (devnode removed)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -301,7 +393,7 @@ fn real_main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
// USER-session WGC helper (Windows two-process secure-desktop design): capture the EXISTING
|
// USER-session WGC helper (Windows two-process secure-desktop design): capture the EXISTING
|
||||||
// SudoVDA via WGC + NVENC, stream AUs on stdout to the SYSTEM host. Spawned by the host
|
// SudoVDA via WGC + NVENC, stream AUs on stdout to the SYSTEM host. Spawned by the host
|
||||||
// (CreateProcessAsUser), not run by hand. See docs/windows-secure-desktop.md.
|
// (CreateProcessAsUser), not run by hand. See design/archive/windows-secure-desktop.md.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
Some("wgc-helper") => {
|
Some("wgc-helper") => {
|
||||||
let get = |flag: &str| {
|
let get = |flag: &str| {
|
||||||
@@ -333,6 +425,12 @@ fn real_main() -> Result<()> {
|
|||||||
// that launches the host into the active interactive session.
|
// that launches the host into the active interactive session.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
Some("service") => service::main(&args[1..]),
|
Some("service") => service::main(&args[1..]),
|
||||||
|
// Install-time work the Windows installer delegates to the exe instead of locale-parsed
|
||||||
|
// PowerShell *files* (the ANSI-codepage parse-break root fix; see windows/install.rs).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
Some("driver") => install::driver_main(&args[1..]),
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
Some("web") => install::web_main(&args[1..]),
|
||||||
Some("-h") | Some("--help") | Some("help") | None => {
|
Some("-h") | Some("--help") | Some("help") | None => {
|
||||||
print_usage();
|
print_usage();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -616,7 +714,7 @@ SPIKE OPTIONS:
|
|||||||
|
|
||||||
NOTES:
|
NOTES:
|
||||||
'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session
|
'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session
|
||||||
(see docs/linux-setup.md). 'synthetic' needs no capture session and always runs.
|
(see design/linux-setup.md). 'synthetic' needs no capture session and always runs.
|
||||||
Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a
|
Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a
|
||||||
punktfunk_core host→client loopback that reassembles and byte-verifies each one.
|
punktfunk_core host→client loopback that reassembles and byte-verifies each one.
|
||||||
Both 'serve' and 'punktfunk1-host' advertise the native service over mDNS
|
Both 'serve' and 'punktfunk1-host' advertise the native service over mDNS
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! The API is versioned under `/api/v1` and described by an OpenAPI 3.1 document generated
|
//! The API is versioned under `/api/v1` and described by an OpenAPI 3.1 document generated
|
||||||
//! at compile time with `utoipa` — `punktfunk-host openapi` prints it for client codegen, the
|
//! at compile time with `utoipa` — `punktfunk-host openapi` prints it for client codegen, the
|
||||||
//! running server serves it at `/api/v1/openapi.json` plus interactive docs at `/api/docs`,
|
//! running server serves it at `/api/v1/openapi.json` plus interactive docs at `/api/docs`,
|
||||||
//! and a copy is checked in at `docs/api/openapi.json` (a test fails if it drifts, like the
|
//! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the
|
||||||
//! cbindgen header).
|
//! cbindgen header).
|
||||||
//!
|
//!
|
||||||
//! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires
|
//! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires
|
||||||
@@ -20,6 +20,7 @@ use crate::gamestream::{
|
|||||||
tls::{serve_https, PeerCertFingerprint},
|
tls::{serve_https, PeerCertFingerprint},
|
||||||
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
|
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
|
||||||
};
|
};
|
||||||
|
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Request, State},
|
extract::{Path, Request, State},
|
||||||
@@ -66,6 +67,9 @@ struct MgmtState {
|
|||||||
/// Native (punktfunk/1) pairing — shared with the QUIC host when the unified `serve --native`
|
/// Native (punktfunk/1) pairing — shared with the QUIC host when the unified `serve --native`
|
||||||
/// runs it. `None` ⇒ GameStream-only host (the native endpoints report `enabled: false`).
|
/// runs it. `None` ⇒ GameStream-only host (the native endpoints report `enabled: false`).
|
||||||
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
||||||
|
/// Shared streaming-stats recorder — the same handle the streaming loops emit into, so an
|
||||||
|
/// operator can arm/stop a capture here and review/list/delete saved recordings.
|
||||||
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
|
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -77,6 +81,7 @@ pub async fn run(
|
|||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
opts: Options,
|
opts: Options,
|
||||||
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
||||||
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve`
|
// The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve`
|
||||||
// guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated).
|
// guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated).
|
||||||
@@ -100,7 +105,7 @@ pub async fn run(
|
|||||||
auth = "mTLS (paired cert) or bearer (required)",
|
auth = "mTLS (paired cert) or bearer (required)",
|
||||||
"management API listening over HTTPS (docs at /api/docs, spec at /api/v1/openapi.json)"
|
"management API listening over HTTPS (docs at /api/docs, spec at /api/v1/openapi.json)"
|
||||||
);
|
);
|
||||||
let app = app(state, Some(token), opts.bind.port(), native);
|
let app = app(state, Some(token), opts.bind.port(), native, stats);
|
||||||
serve_https(opts.bind, app, tls).await
|
serve_https(opts.bind, app, tls).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,10 +115,12 @@ fn app(
|
|||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
port: u16,
|
port: u16,
|
||||||
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
||||||
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
) -> Router {
|
) -> Router {
|
||||||
let shared = Arc::new(MgmtState {
|
let shared = Arc::new(MgmtState {
|
||||||
app: state,
|
app: state,
|
||||||
native,
|
native,
|
||||||
|
stats,
|
||||||
token,
|
token,
|
||||||
port,
|
port,
|
||||||
});
|
});
|
||||||
@@ -158,13 +165,19 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(request_idr))
|
.routes(routes!(request_idr))
|
||||||
.routes(routes!(get_library))
|
.routes(routes!(get_library))
|
||||||
.routes(routes!(create_custom_game))
|
.routes(routes!(create_custom_game))
|
||||||
.routes(routes!(update_custom_game, delete_custom_game)),
|
.routes(routes!(update_custom_game, delete_custom_game))
|
||||||
|
.routes(routes!(stats_capture_start))
|
||||||
|
.routes(routes!(stats_capture_stop))
|
||||||
|
.routes(routes!(stats_capture_status))
|
||||||
|
.routes(routes!(stats_capture_live))
|
||||||
|
.routes(routes!(stats_recordings_list))
|
||||||
|
.routes(routes!(stats_recording_get, stats_recording_delete)),
|
||||||
)
|
)
|
||||||
.split_for_parts()
|
.split_for_parts()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The OpenAPI document as pretty JSON — what `punktfunk-host openapi` prints and what is
|
/// The OpenAPI document as pretty JSON — what `punktfunk-host openapi` prints and what is
|
||||||
/// checked in at `docs/api/openapi.json` for client codegen.
|
/// checked in at `api/openapi.json` for client codegen.
|
||||||
pub fn openapi_json() -> String {
|
pub fn openapi_json() -> String {
|
||||||
let (_, api) = api_router_parts();
|
let (_, api) = api_router_parts();
|
||||||
let mut json = api.to_pretty_json().expect("serialize OpenAPI document");
|
let mut json = api.to_pretty_json().expect("serialize OpenAPI document");
|
||||||
@@ -190,6 +203,7 @@ pub fn openapi_json() -> String {
|
|||||||
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
|
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
|
||||||
(name = "session", description = "Active streaming session control"),
|
(name = "session", description = "Active streaming session control"),
|
||||||
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
|
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
|
||||||
|
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
@@ -1218,6 +1232,185 @@ async fn delete_custom_game(Path(id): Path<String>) -> Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Streaming stats capture (design/stats-capture-plan.md §2)
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Start a stats capture
|
||||||
|
///
|
||||||
|
/// Arms a new performance-stats capture. Idempotent: if a capture is already running this returns
|
||||||
|
/// the current status unchanged. While armed, the streaming loops emit aggregated samples (~ every
|
||||||
|
/// 1–2 s) into the in-progress capture, readable live via `GET /stats/capture/live`.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/stats/capture/start",
|
||||||
|
tag = "stats",
|
||||||
|
operation_id = "statsCaptureStart",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Capture armed (or already running)", body = StatsStatus),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn stats_capture_start(State(st): State<Arc<MgmtState>>) -> Json<StatsStatus> {
|
||||||
|
let status = st.stats.start();
|
||||||
|
tracing::info!(
|
||||||
|
started_unix_ms = status.started_unix_ms,
|
||||||
|
"management API: stats capture armed"
|
||||||
|
);
|
||||||
|
Json(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the stats capture
|
||||||
|
///
|
||||||
|
/// Disarms the in-progress capture and writes it to disk atomically, returning its summary. If
|
||||||
|
/// nothing was recording, returns `204 No Content`.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/stats/capture/stop",
|
||||||
|
tag = "stats",
|
||||||
|
operation_id = "statsCaptureStop",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Capture stopped and saved", body = CaptureMeta),
|
||||||
|
(status = NO_CONTENT, description = "Nothing was recording"),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not write the recording to disk", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn stats_capture_stop(State(st): State<Arc<MgmtState>>) -> Response {
|
||||||
|
match st.stats.stop() {
|
||||||
|
Ok(Some(meta)) => {
|
||||||
|
tracing::info!(id = %meta.id, samples = meta.sample_count, "management API: stats capture saved");
|
||||||
|
(StatusCode::OK, Json(meta)).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("could not save capture: {e}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stats capture status
|
||||||
|
///
|
||||||
|
/// Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to
|
||||||
|
/// drive the capture-control UI.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/stats/capture/status",
|
||||||
|
tag = "stats",
|
||||||
|
operation_id = "statsCaptureStatus",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "In-progress capture status (idle when not armed)", body = StatsStatus),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn stats_capture_status(State(st): State<Arc<MgmtState>>) -> Json<StatsStatus> {
|
||||||
|
Json(st.stats.status())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live in-progress capture
|
||||||
|
///
|
||||||
|
/// The full sample time-series of the capture currently recording, for live graphing. `404` when
|
||||||
|
/// nothing is armed.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/stats/capture/live",
|
||||||
|
tag = "stats",
|
||||||
|
operation_id = "statsCaptureLive",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The in-progress capture (meta + samples so far)", body = Capture),
|
||||||
|
(status = NOT_FOUND, description = "No capture is currently recording", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn stats_capture_live(State(st): State<Arc<MgmtState>>) -> Response {
|
||||||
|
match st.stats.live_snapshot() {
|
||||||
|
Some(capture) => Json(capture).into_response(),
|
||||||
|
None => api_error(StatusCode::NOT_FOUND, "no capture is currently recording"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List saved recordings
|
||||||
|
///
|
||||||
|
/// Every saved capture's summary (the `meta` head only — not the sample body), newest first.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/stats/recordings",
|
||||||
|
tag = "stats",
|
||||||
|
operation_id = "statsRecordingsList",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Saved capture summaries, newest first", body = [CaptureMeta]),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn stats_recordings_list(State(st): State<Arc<MgmtState>>) -> Json<Vec<CaptureMeta>> {
|
||||||
|
Json(st.stats.list())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a saved recording
|
||||||
|
///
|
||||||
|
/// The full capture (meta + samples) for `id`, for graphing or download.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/stats/recordings/{id}",
|
||||||
|
tag = "stats",
|
||||||
|
operation_id = "statsRecordingGet",
|
||||||
|
params(("id" = String, Path, description = "The recording id (its filename stem)")),
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The full capture", body = Capture),
|
||||||
|
(status = NOT_FOUND, description = "No recording with that id", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "The recording file is unreadable", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn stats_recording_get(State(st): State<Arc<MgmtState>>, Path(id): Path<String>) -> Response {
|
||||||
|
match st.stats.load(&id) {
|
||||||
|
Ok(capture) => Json(capture).into_response(),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
api_error(StatusCode::NOT_FOUND, "no recording with that id")
|
||||||
|
}
|
||||||
|
Err(e) => api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("could not read recording: {e}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a saved recording
|
||||||
|
///
|
||||||
|
/// Removes the recording `id` from disk. `404` if there is no such recording.
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/stats/recordings/{id}",
|
||||||
|
tag = "stats",
|
||||||
|
operation_id = "statsRecordingDelete",
|
||||||
|
params(("id" = String, Path, description = "The recording id (its filename stem)")),
|
||||||
|
responses(
|
||||||
|
(status = NO_CONTENT, description = "Recording deleted"),
|
||||||
|
(status = NOT_FOUND, description = "No recording with that id", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not delete the recording", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn stats_recording_delete(
|
||||||
|
State(st): State<Arc<MgmtState>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
match st.stats.delete(&id) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(id, "management API: recording deleted");
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
api_error(StatusCode::NOT_FOUND, "no recording with that id")
|
||||||
|
}
|
||||||
|
Err(e) => api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("could not delete recording: {e}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
@@ -1231,6 +1424,15 @@ mod tests {
|
|||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
/// A throwaway stats recorder rooted in a unique temp dir (never touches the real config dir).
|
||||||
|
fn test_stats() -> Arc<crate::stats_recorder::StatsRecorder> {
|
||||||
|
crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!(
|
||||||
|
"pf-mgmt-stats-{}-{:p}",
|
||||||
|
std::process::id(),
|
||||||
|
&0u8 as *const u8
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
fn test_state() -> Arc<AppState> {
|
fn test_state() -> Arc<AppState> {
|
||||||
let host = Host {
|
let host = Host {
|
||||||
hostname: "test-host".into(),
|
hostname: "test-host".into(),
|
||||||
@@ -1240,18 +1442,20 @@ mod tests {
|
|||||||
https_port: HTTPS_PORT,
|
https_port: HTTPS_PORT,
|
||||||
};
|
};
|
||||||
let identity = ServerIdentity::ephemeral().expect("ephemeral identity");
|
let identity = ServerIdentity::ephemeral().expect("ephemeral identity");
|
||||||
Arc::new(AppState::new(host, identity))
|
Arc::new(AppState::new(host, identity, test_stats()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mgmt API now always requires auth, so the router always has a token. A test that passes
|
// The mgmt API now always requires auth, so the router always has a token. A test that passes
|
||||||
// `None` gets the default "test-secret" (and `send` auto-attaches the matching bearer); a test
|
// `None` gets the default "test-secret" (and `send` auto-attaches the matching bearer); a test
|
||||||
// that passes an explicit token exercises a mismatch (e.g. `bearer_token_is_enforced`).
|
// that passes an explicit token exercises a mismatch (e.g. `bearer_token_is_enforced`).
|
||||||
fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
|
fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
|
||||||
|
let stats = state.stats.clone();
|
||||||
app(
|
app(
|
||||||
state,
|
state,
|
||||||
Some(token.unwrap_or("test-secret").to_string()),
|
Some(token.unwrap_or("test-secret").to_string()),
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
None,
|
None,
|
||||||
|
stats,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1261,11 +1465,13 @@ mod tests {
|
|||||||
) -> Router {
|
) -> Router {
|
||||||
// Auth required always; the paired-cert tests inject a fingerprint (cert branch wins), the
|
// Auth required always; the paired-cert tests inject a fingerprint (cert branch wins), the
|
||||||
// rest authenticate via the `send`-attached default bearer.
|
// rest authenticate via the `send`-attached default bearer.
|
||||||
|
let stats = state.stats.clone();
|
||||||
app(
|
app(
|
||||||
state,
|
state,
|
||||||
Some("test-secret".to_string()),
|
Some("test-secret".to_string()),
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
Some(np),
|
Some(np),
|
||||||
|
stats,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1580,7 +1786,9 @@ mod tests {
|
|||||||
bind: "127.0.0.1:0".parse().unwrap(),
|
bind: "127.0.0.1:0".parse().unwrap(),
|
||||||
token: Some(" ".into()),
|
token: Some(" ".into()),
|
||||||
};
|
};
|
||||||
let err = run(test_state(), opts, None).await.unwrap_err();
|
let err = run(test_state(), opts, None, test_stats())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("no token"), "{err}");
|
assert!(err.to_string().contains("no token"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1663,14 +1871,14 @@ mod tests {
|
|||||||
serde_json::json!([{}])
|
serde_json::json!([{}])
|
||||||
);
|
);
|
||||||
|
|
||||||
let checked_in = include_str!("../../../docs/api/openapi.json");
|
let checked_in = include_str!("../../../api/openapi.json");
|
||||||
// Compare content, not line-ending style: the generated `json` is LF (serde_json), but git
|
// Compare content, not line-ending style: the generated `json` is LF (serde_json), but git
|
||||||
// may check the file out CRLF on Windows.
|
// may check the file out CRLF on Windows.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
json.trim().replace('\r', ""),
|
json.trim().replace('\r', ""),
|
||||||
checked_in.trim().replace('\r', ""),
|
checked_in.trim().replace('\r', ""),
|
||||||
"docs/api/openapi.json is stale — regenerate with: \
|
"api/openapi.json is stale — regenerate with: \
|
||||||
cargo run -p punktfunk-host -- openapi > docs/api/openapi.json"
|
cargo run -p punktfunk-host -- openapi > api/openapi.json"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
|||||||
|
//! `SessionPlan` — the per-session capture / topology / encoder decision, resolved **once** from
|
||||||
|
//! [`HostConfig`](crate::config) (+ the handshake-negotiated bit depth) into a typed, logged value.
|
||||||
|
//!
|
||||||
|
//! **Goal-1 stage 3** (`design/windows-host-rewrite.md` §2.2): before this, the Windows session decision was
|
||||||
|
//! re-derived at three call sites — the capture backend inside `capture::capture_virtual_output`, the
|
||||||
|
//! process topology in `punktfunk1::should_use_helper`, and the encode backend in
|
||||||
|
//! `encode::windows_resolved_backend` — each reading [`config`](crate::config) independently, with no
|
||||||
|
//! single owner (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `SessionPlan`
|
||||||
|
//! resolves them together, once, so the deployed path reads one typed artifact.
|
||||||
|
//!
|
||||||
|
//! Stage 3 routes the **capture** and **topology** decisions through the plan (see
|
||||||
|
//! `capture::capture_virtual_output` taking [`CaptureBackend`] in, and `virtual_stream` reading
|
||||||
|
//! [`SessionTopology`]). The **encoder** is resolved by `encode::windows_resolved_backend` (config-backed
|
||||||
|
//! and GPU-vendor cached since stage 2, so already a single source) and *recorded* here as
|
||||||
|
//! [`EncoderBackend`]. Threading `encoder`/`input_format` into the encoder + capturer opens — which
|
||||||
|
//! removes the `capture → encode::windows_resolved_backend()` back-reference recomputed in `dxgi.rs` —
|
||||||
|
//! is **stage 5**.
|
||||||
|
//!
|
||||||
|
//! The type is platform-neutral so it threads through the shared `virtual_stream`/`build_pipeline`
|
||||||
|
//! signatures; on Linux it resolves to the single portal/single-process path (the 3-way dispatch is a
|
||||||
|
//! Windows-only concern).
|
||||||
|
|
||||||
|
/// Where a session's frames come from.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum CaptureBackend {
|
||||||
|
/// Linux: the xdg ScreenCast portal → PipeWire (the only Linux capture path).
|
||||||
|
Portal,
|
||||||
|
/// Windows: IDD direct-push — frames pulled straight from the pf-vdisplay driver's shared ring
|
||||||
|
/// (in-process, Session 0; no Desktop Duplication, no WGC helper).
|
||||||
|
IddPush,
|
||||||
|
/// Windows: DXGI Desktop Duplication (`PUNKTFUNK_CAPTURE=dda|dxgi` or `PUNKTFUNK_NO_WGC`).
|
||||||
|
Dda,
|
||||||
|
/// Windows: Windows.Graphics.Capture (the composed-desktop default), with a DDA watchdog fallback.
|
||||||
|
Wgc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureBackend {
|
||||||
|
/// Resolve the capture backend from [`config`](crate::config). This is the single resolver shared by
|
||||||
|
/// [`SessionPlan::resolve`] and the standalone callers (GameStream / spike), so they can't drift.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn resolve() -> Self {
|
||||||
|
CaptureBackend::Portal
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows precedence (identical to the pre-stage-3 `capture_virtual_output` branch order):
|
||||||
|
/// IDD-push wins; else an explicit `dda`/`dxgi` request or `PUNKTFUNK_NO_WGC` selects DDA; else WGC.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn resolve() -> Self {
|
||||||
|
let cfg = crate::config::config();
|
||||||
|
if cfg.idd_push {
|
||||||
|
CaptureBackend::IddPush
|
||||||
|
} else if matches!(cfg.capture_backend.as_str(), "dda" | "dxgi")
|
||||||
|
|| crate::capture::wgc_disabled()
|
||||||
|
{
|
||||||
|
CaptureBackend::Dda
|
||||||
|
} else {
|
||||||
|
CaptureBackend::Wgc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
pub fn resolve() -> Self {
|
||||||
|
CaptureBackend::Portal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a session is structured across processes.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SessionTopology {
|
||||||
|
/// One process captures + encodes (Linux; Windows non-SYSTEM / IDD-push / `NO_WGC`).
|
||||||
|
SingleProcess,
|
||||||
|
/// SYSTEM host + a user-session WGC helper relay (the Windows normal-desktop path under SYSTEM,
|
||||||
|
/// where in-process WGC can't activate). See `virtual_stream_relay`.
|
||||||
|
TwoProcessRelay,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolved encode backend (recorded for logging / stages 4–5; the per-session encoder open still
|
||||||
|
/// resolves via `encode::windows_resolved_backend`, which is config-backed + GPU-vendor cached).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum EncoderBackend {
|
||||||
|
/// Linux: NVENC vs VAAPI is auto-detected inside `encode::open_video` (not modeled here).
|
||||||
|
PlatformAuto,
|
||||||
|
Nvenc,
|
||||||
|
Amf,
|
||||||
|
Qsv,
|
||||||
|
Software,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncoderBackend {
|
||||||
|
/// True if this backend encodes on the GPU (so the capturer should produce GPU-resident frames). Only
|
||||||
|
/// the software encoder takes CPU staging; `PlatformAuto` (Linux NVENC/VAAPI) is always GPU.
|
||||||
|
pub fn is_gpu(self) -> bool {
|
||||||
|
!matches!(self, EncoderBackend::Software)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-session decision, resolved once. `Copy` so it threads through the capture/encode chain
|
||||||
|
/// without ceremony (stage 4 folds it, with the rest of the arg soup, into a `SessionContext`).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct SessionPlan {
|
||||||
|
pub capture: CaptureBackend,
|
||||||
|
pub topology: SessionTopology,
|
||||||
|
pub encoder: EncoderBackend,
|
||||||
|
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10).
|
||||||
|
pub bit_depth: u8,
|
||||||
|
/// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag the capturer was passed before.
|
||||||
|
/// Non-IDD-push Windows backends ignore it and auto-detect HDR from the monitor; Linux is 8-bit.
|
||||||
|
pub hdr: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionPlan {
|
||||||
|
/// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth`.
|
||||||
|
pub fn resolve(bit_depth: u8) -> Self {
|
||||||
|
SessionPlan {
|
||||||
|
capture: CaptureBackend::resolve(),
|
||||||
|
topology: resolve_topology(),
|
||||||
|
encoder: resolve_encoder(),
|
||||||
|
bit_depth,
|
||||||
|
hdr: bit_depth >= 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The capturer's target output format (Goal-1 stage 5): `gpu` from the already-resolved `encoder`
|
||||||
|
/// (no second backend probe), `hdr` from the plan. Handed into `capture::capture_virtual_output` so the
|
||||||
|
/// capturer never re-derives the encode backend.
|
||||||
|
pub fn output_format(&self) -> crate::capture::OutputFormat {
|
||||||
|
crate::capture::OutputFormat {
|
||||||
|
gpu: self.encoder.is_gpu(),
|
||||||
|
hdr: self.hdr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process topology. On Windows this is the former `punktfunk1::should_use_helper` logic verbatim; on
|
||||||
|
/// every other platform the session is always single-process.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn resolve_topology() -> SessionTopology {
|
||||||
|
let cfg = crate::config::config();
|
||||||
|
// `NO_HELPER`/`NO_WGC` force single-process; IDD-push captures in-process in Session 0 (no helper);
|
||||||
|
// otherwise the helper runs when forced or when we're SYSTEM (in-process WGC can't activate there).
|
||||||
|
let helper = if cfg.no_helper || crate::capture::wgc_disabled() || cfg.idd_push {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
cfg.force_helper || crate::capture::wgc_relay::running_as_system()
|
||||||
|
};
|
||||||
|
if helper {
|
||||||
|
SessionTopology::TwoProcessRelay
|
||||||
|
} else {
|
||||||
|
SessionTopology::SingleProcess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn resolve_topology() -> SessionTopology {
|
||||||
|
SessionTopology::SingleProcess
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn resolve_encoder() -> EncoderBackend {
|
||||||
|
match crate::encode::windows_resolved_backend() {
|
||||||
|
crate::encode::WindowsBackend::Nvenc => EncoderBackend::Nvenc,
|
||||||
|
crate::encode::WindowsBackend::Amf => EncoderBackend::Amf,
|
||||||
|
crate::encode::WindowsBackend::Qsv => EncoderBackend::Qsv,
|
||||||
|
crate::encode::WindowsBackend::Software => EncoderBackend::Software,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn resolve_encoder() -> EncoderBackend {
|
||||||
|
EncoderBackend::PlatformAuto
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@
|
|||||||
//! Raw C-ABI FFI (winmm/kernel32/dwmapi/avrt) rather than the `windows` crate so it builds without
|
//! Raw C-ABI FFI (winmm/kernel32/dwmapi/avrt) rather than the `windows` crate so it builds without
|
||||||
//! pulling new windows-rs features. No-op on non-Windows. Per-thread effects (MMCSS, execution
|
//! pulling new windows-rs features. No-op on non-Windows. Per-thread effects (MMCSS, execution
|
||||||
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
|
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
|
||||||
//! See `docs/host-latency-plan.md` Tier 3A.
|
//! See `design/host-latency-plan.md` Tier 3A.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod imp {
|
mod imp {
|
||||||
@@ -49,6 +52,10 @@ mod imp {
|
|||||||
/// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is
|
/// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is
|
||||||
/// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
/// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||||
fn tune_process_once() {
|
fn tune_process_once() {
|
||||||
|
// SAFETY: each call is a C-ABI FFI into winmm/kernel32/dwmapi declared with a matching
|
||||||
|
// `extern "system"` signature; every argument is a plain integer (no pointers/buffers escape),
|
||||||
|
// and `GetCurrentProcess()` returns the current-process pseudo-handle (a constant, always valid,
|
||||||
|
// never closed). The body runs inside `get_or_init`, so it executes exactly once per process.
|
||||||
PROCESS_TUNED.get_or_init(|| unsafe {
|
PROCESS_TUNED.get_or_init(|| unsafe {
|
||||||
// 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the
|
// 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the
|
||||||
// encode|send split's sub-ms sleeps.
|
// encode|send split's sub-ms sleeps.
|
||||||
@@ -70,6 +77,11 @@ mod imp {
|
|||||||
/// thread exits, so a session that ends tears them down without explicit bookkeeping.
|
/// thread exits, so a session that ends tears them down without explicit bookkeeping.
|
||||||
pub fn on_hot_thread() {
|
pub fn on_hot_thread() {
|
||||||
tune_process_once();
|
tune_process_once();
|
||||||
|
// SAFETY: C-ABI FFI declared with matching `extern "system"` signatures. SetThreadExecutionState
|
||||||
|
// takes only flag bits. `task` is a local NUL-terminated UTF-16 buffer ("Games\0") alive for the
|
||||||
|
// whole block, so `task.as_ptr()` is a valid LPCWSTR for the call, and `&mut idx` is a live local
|
||||||
|
// u32 the call writes the task index into. The returned MMCSS handle is intentionally leaked (the
|
||||||
|
// OS reverts the characteristics at thread exit), so there is nothing to free or double-free.
|
||||||
unsafe {
|
unsafe {
|
||||||
SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED);
|
SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED);
|
||||||
let task: Vec<u16> = "Games\0".encode_utf16().collect();
|
let task: Vec<u16> = "Games\0".encode_utf16().collect();
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ pub fn run(opts: Options) -> Result<()> {
|
|||||||
refresh_hz: opts.fps,
|
refresh_hz: opts.fps,
|
||||||
})
|
})
|
||||||
.context("create virtual output")?;
|
.context("create virtual output")?;
|
||||||
capture::capture_virtual_output(vout).context("capture virtual output")?
|
capture::capture_virtual_output(
|
||||||
|
vout,
|
||||||
|
capture::OutputFormat::resolve(false),
|
||||||
|
crate::session_plan::CaptureBackend::resolve(),
|
||||||
|
)
|
||||||
|
.context("capture virtual output")?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
//! Shared streaming-stats recorder (`design/stats-capture-plan.md` §1). One
|
||||||
|
//! [`StatsRecorder`] handle is created once in the unified host entry
|
||||||
|
//! (`gamestream::serve`) alongside [`crate::native_pairing::NativePairing`], and shared with
|
||||||
|
//! **both** the management API ([`crate::mgmt`]) and the streaming loops (threaded through
|
||||||
|
//! [`crate::punktfunk1::serve`] → `SessionContext` and into the GameStream encode loop). The
|
||||||
|
//! operator arms a capture from the web console, plays a session, stops, and reviews the
|
||||||
|
//! captured time-series as graphs; captures are saved to disk and survive a host restart.
|
||||||
|
//!
|
||||||
|
//! Hot-path discipline: [`StatsRecorder::is_armed`] is a cheap `Relaxed` atomic load (re-read
|
||||||
|
//! per frame); sample construction happens only at the loops' existing ~2 s / ~1 s aggregation
|
||||||
|
//! boundary, never per frame. Memory is bounded ([`MAX_SAMPLES`]); the on-disk write is atomic
|
||||||
|
//! (temp + rename); and capture ids are path-traversal-safe.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
/// Cap on samples kept in one capture: ≈ 3 h at one sample / 2 s. On overflow we stop appending
|
||||||
|
/// (keeping the oldest — a saved recording must keep its start), never dropping the front and never
|
||||||
|
/// growing unbounded.
|
||||||
|
const MAX_SAMPLES: usize = 5400;
|
||||||
|
|
||||||
|
/// One pipeline stage's latency in an aggregation window (microseconds).
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||||
|
pub struct StageTiming {
|
||||||
|
/// `"capture" | "submit" | "encode" | "packetize" | "send"` (path-dependent).
|
||||||
|
pub name: String,
|
||||||
|
pub p50_us: f32,
|
||||||
|
pub p99_us: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||||
|
pub struct StatsSample {
|
||||||
|
/// Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).
|
||||||
|
pub t_ms: u64,
|
||||||
|
/// Disambiguates concurrent sessions (usually constant).
|
||||||
|
pub session_id: u32,
|
||||||
|
/// Ordered pipeline stages for this path.
|
||||||
|
pub stages: Vec<StageTiming>,
|
||||||
|
/// Genuine NEW frames/s from the source.
|
||||||
|
pub fps: f32,
|
||||||
|
/// Re-encoded holds/s (source-starvation indicator).
|
||||||
|
pub repeat_fps: f32,
|
||||||
|
/// Transmit goodput (Mb/s).
|
||||||
|
pub mbps: f32,
|
||||||
|
/// Configured target bitrate.
|
||||||
|
pub bitrate_kbps: u32,
|
||||||
|
/// Frames dropped this window (delta).
|
||||||
|
pub frames_dropped: u32,
|
||||||
|
/// Packets dropped this window (receiver-side / reassembler, where known).
|
||||||
|
pub packets_dropped: u32,
|
||||||
|
/// Host send-buffer overflow / EAGAIN this window (delta).
|
||||||
|
pub send_dropped: u32,
|
||||||
|
/// FEC shards recovered this window (delta).
|
||||||
|
pub fec_recovered: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head
|
||||||
|
/// of each on-disk recording and listed standalone (without the sample body) by
|
||||||
|
/// [`StatsRecorder::list`].
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||||
|
pub struct CaptureMeta {
|
||||||
|
/// e.g. `"2026-06-26T20-14-03Z_5120x1440"` — also the filename stem.
|
||||||
|
pub id: String,
|
||||||
|
pub started_unix_ms: u64,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
/// `"native" | "gamestream"`.
|
||||||
|
pub kind: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fps: u32,
|
||||||
|
/// `"h264" | "hevc" | "av1"`.
|
||||||
|
pub codec: String,
|
||||||
|
/// Short label / fingerprint prefix, or `""` if unknown.
|
||||||
|
pub client: String,
|
||||||
|
pub sample_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full capture: summary + the sample time-series. The wire + on-disk shape.
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||||
|
pub struct Capture {
|
||||||
|
pub meta: CaptureMeta,
|
||||||
|
pub samples: Vec<StatsSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the in-progress capture for the management API.
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||||
|
pub struct StatsStatus {
|
||||||
|
/// Capture currently running.
|
||||||
|
pub armed: bool,
|
||||||
|
/// Samples in the in-progress capture.
|
||||||
|
pub sample_count: u32,
|
||||||
|
/// Unix start time of the in-progress capture (`0` if idle).
|
||||||
|
pub started_unix_ms: u64,
|
||||||
|
/// Path of the in-progress capture (`""` if idle).
|
||||||
|
pub kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode/codec/client seeded on the first [`StatsRecorder::register_session`] of a capture.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct MetaSeed {
|
||||||
|
kind: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fps: u32,
|
||||||
|
codec: String,
|
||||||
|
client: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-progress capture (present iff armed).
|
||||||
|
struct Live {
|
||||||
|
/// Monotonic clock origin for sample `t_ms`.
|
||||||
|
started: Instant,
|
||||||
|
started_unix_ms: u64,
|
||||||
|
/// Seeded once, on the first session registration.
|
||||||
|
meta: Option<MetaSeed>,
|
||||||
|
samples: Vec<StatsSample>,
|
||||||
|
/// Set once the sample cap was hit (further samples dropped). Read so it isn't dead.
|
||||||
|
truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared streaming-stats recorder: an arm/disarm flag (the hot-path gate), the in-progress
|
||||||
|
/// capture, and the on-disk capture directory.
|
||||||
|
pub struct StatsRecorder {
|
||||||
|
dir: PathBuf,
|
||||||
|
/// The hot-path gate — a `Relaxed` load per frame; never blocks the frame thread.
|
||||||
|
armed: AtomicBool,
|
||||||
|
/// The in-progress capture. Locks recover a poisoned guard (`unwrap_or_else(|e| e.into_inner())`,
|
||||||
|
/// as in `vdisplay::gamescope`) rather than `unwrap()`: a panic somewhere must never make stats
|
||||||
|
/// recording crash an otherwise-healthy stream. The critical sections only push/clone/format, so
|
||||||
|
/// poisoning is near-impossible anyway — this is belt-and-suspenders.
|
||||||
|
live: Mutex<Option<Live>>,
|
||||||
|
next_sid: AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default captures directory: `~/.config/punktfunk/captures/` (next to `cert.pem`),
|
||||||
|
/// resolved via the same config-dir helper the rest of the host uses.
|
||||||
|
pub fn default_dir() -> PathBuf {
|
||||||
|
crate::gamestream::config_dir().join("captures")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `id` charset gate, matching `^[A-Za-z0-9._-]+$` — the exact charset `capture_id` emits (which
|
||||||
|
/// deliberately uses dashes, not colons, so the stem is a valid Windows filename). We additionally
|
||||||
|
/// reject `.`/`..` so a path-component sneaks no parent reference even though the charset would allow
|
||||||
|
/// bare dots. The charset already excludes `/` and `\`, so `dir.join("<id>.json")` is always a single
|
||||||
|
/// child of `dir`. Defense in depth — the endpoints are bearer-authed.
|
||||||
|
fn valid_id(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id != "."
|
||||||
|
&& id != ".."
|
||||||
|
&& id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_ms_now() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A human-readable, filesystem-safe capture id from the start time + mode, e.g.
|
||||||
|
/// `2026-06-26T20-14-03Z_5120x1440`. Dashes (not colons) in the time so it's a valid Windows
|
||||||
|
/// filename; matches [`valid_id`].
|
||||||
|
fn capture_id(unix_ms: u64, width: u32, height: u32) -> String {
|
||||||
|
let secs = (unix_ms / 1000) as i64;
|
||||||
|
let days = secs.div_euclid(86_400);
|
||||||
|
let tod = secs.rem_euclid(86_400);
|
||||||
|
let (y, mo, d) = civil_from_days(days);
|
||||||
|
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||||
|
format!("{y:04}-{mo:02}-{d:02}T{h:02}-{mi:02}-{s:02}Z_{width}x{height}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Civil (Y, M, D) from a count of days since the Unix epoch (Howard Hinnant's `civil_from_days`).
|
||||||
|
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||||
|
let z = z + 719_468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
|
||||||
|
let doe = z - era * 146_097; // [0, 146096]
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||||
|
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||||
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||||
|
(if m <= 2 { y + 1 } else { y }, m as u32, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsRecorder {
|
||||||
|
/// Create the recorder, creating `dir` (owner-private, best-effort) if missing.
|
||||||
|
pub fn new(dir: PathBuf) -> Arc<Self> {
|
||||||
|
if let Err(e) = crate::gamestream::create_private_dir(&dir) {
|
||||||
|
tracing::warn!(dir = %dir.display(), error = %e, "could not create stats captures dir");
|
||||||
|
}
|
||||||
|
Arc::new(StatsRecorder {
|
||||||
|
dir,
|
||||||
|
armed: AtomicBool::new(false),
|
||||||
|
live: Mutex::new(None),
|
||||||
|
next_sid: AtomicU32::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hot-path gate: cheap `Relaxed` load, called per frame to decide whether to measure.
|
||||||
|
pub fn is_armed(&self) -> bool {
|
||||||
|
self.armed.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arm a new capture. No-op if already armed (returns the current status).
|
||||||
|
pub fn start(&self) -> StatsStatus {
|
||||||
|
let mut guard = self.live.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if guard.is_none() {
|
||||||
|
*guard = Some(Live {
|
||||||
|
started: Instant::now(),
|
||||||
|
started_unix_ms: unix_ms_now(),
|
||||||
|
meta: None,
|
||||||
|
samples: Vec::new(),
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
// Publish AFTER the live capture exists, so a frame thread that observes `armed` always
|
||||||
|
// finds a capture to push into.
|
||||||
|
self.armed.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
status_of(guard.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A streaming loop announces itself when it first records while armed. Seeds the capture's
|
||||||
|
/// `CaptureMeta` (kind/w/h/fps/codec/client) on the FIRST registration; returns a session id
|
||||||
|
/// to stamp on the loop's samples.
|
||||||
|
pub fn register_session(
|
||||||
|
&self,
|
||||||
|
kind: &'static str,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
fps: u32,
|
||||||
|
codec: &str,
|
||||||
|
client: &str,
|
||||||
|
) -> u32 {
|
||||||
|
let sid = self.next_sid.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let mut guard = self.live.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(live) = guard.as_mut() {
|
||||||
|
if live.meta.is_none() {
|
||||||
|
live.meta = Some(MetaSeed {
|
||||||
|
kind: kind.to_string(),
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
fps,
|
||||||
|
codec: codec.to_string(),
|
||||||
|
client: client.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one aggregated sample (called from the loops' existing ~2 s / ~1 s boundary). The
|
||||||
|
/// `t_ms` is (re)stamped here from the capture's monotonic start, so callers may leave it `0`.
|
||||||
|
/// Bounded at [`MAX_SAMPLES`]: on overflow we stop appending (oldest kept) and flag truncation.
|
||||||
|
/// A no-op when nothing is armed (e.g. a `stop()` raced the frame boundary).
|
||||||
|
pub fn push_sample(&self, session_id: u32, mut sample: StatsSample) {
|
||||||
|
let mut guard = self.live.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let Some(live) = guard.as_mut() else { return };
|
||||||
|
if live.samples.len() >= MAX_SAMPLES {
|
||||||
|
if !live.truncated {
|
||||||
|
live.truncated = true;
|
||||||
|
tracing::warn!(
|
||||||
|
max = MAX_SAMPLES,
|
||||||
|
"stats capture hit the sample cap — further samples dropped (oldest kept)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sample.session_id = session_id;
|
||||||
|
sample.t_ms = live.started.elapsed().as_millis() as u64;
|
||||||
|
live.samples.push(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disarm + finalize: write `<dir>/<id>.json` atomically (temp + rename) and return its meta.
|
||||||
|
/// `Ok(None)` if nothing was recording.
|
||||||
|
pub fn stop(&self) -> std::io::Result<Option<CaptureMeta>> {
|
||||||
|
// Clear the hot-path gate first so frame threads stop building samples immediately.
|
||||||
|
self.armed.store(false, Ordering::Relaxed);
|
||||||
|
let Some(live) = self.live.lock().unwrap_or_else(|e| e.into_inner()).take() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let meta = meta_of(&live);
|
||||||
|
let capture = Capture {
|
||||||
|
meta: meta.clone(),
|
||||||
|
samples: live.samples,
|
||||||
|
};
|
||||||
|
let bytes = serde_json::to_vec(&capture).map_err(std::io::Error::other)?;
|
||||||
|
// Atomic replace: write a sibling temp then rename, so a crash mid-write can't leave a half
|
||||||
|
// file. The id is generated (always `valid_id`), so this only ever names a child of `dir`.
|
||||||
|
let path = self.dir.join(format!("{}.json", meta.id));
|
||||||
|
let tmp = self.dir.join(format!("{}.json.tmp", meta.id));
|
||||||
|
std::fs::write(&tmp, &bytes)?;
|
||||||
|
std::fs::rename(&tmp, &path)?;
|
||||||
|
Ok(Some(meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-progress capture status (idle = `armed: false`, zeroed fields).
|
||||||
|
pub fn status(&self) -> StatsStatus {
|
||||||
|
status_of(self.live.lock().unwrap_or_else(|e| e.into_inner()).as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clone of the in-progress capture for live graphing (`None` when idle).
|
||||||
|
pub fn live_snapshot(&self) -> Option<Capture> {
|
||||||
|
let guard = self.live.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let live = guard.as_ref()?;
|
||||||
|
Some(Capture {
|
||||||
|
meta: meta_of(live),
|
||||||
|
samples: live.samples.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All saved recordings, newest first, parsing each file's `meta` head only (not the samples).
|
||||||
|
pub fn list(&self) -> Vec<CaptureMeta> {
|
||||||
|
/// Parse only the `meta` head — serde skips the (large) `samples` array.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MetaOnly {
|
||||||
|
meta: CaptureMeta,
|
||||||
|
}
|
||||||
|
let mut out: Vec<CaptureMeta> = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir(&self.dir) else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(bytes) = std::fs::read(&path) {
|
||||||
|
if let Ok(parsed) = serde_json::from_slice::<MetaOnly>(&bytes) {
|
||||||
|
out.push(parsed.meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by_key(|m| std::cmp::Reverse(m.started_unix_ms));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a saved recording by id. Rejects a path-unsafe id (and a missing file) as `NotFound`.
|
||||||
|
pub fn load(&self, id: &str) -> std::io::Result<Capture> {
|
||||||
|
let path = self.recording_path(id)?;
|
||||||
|
let bytes = std::fs::read(&path)?;
|
||||||
|
serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a saved recording by id. Rejects a path-unsafe id (and a missing file) as `NotFound`.
|
||||||
|
pub fn delete(&self, id: &str) -> std::io::Result<()> {
|
||||||
|
let path = self.recording_path(id)?;
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve `dir/<id>.json` after validating `id`. A rejected id is `NotFound` (defense in
|
||||||
|
/// depth: never let an attacker-shaped id escape `dir`).
|
||||||
|
fn recording_path(&self, id: &str) -> std::io::Result<PathBuf> {
|
||||||
|
if !valid_id(id) {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"invalid recording id",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(self.dir.join(format!("{id}.json")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the live `StatsStatus` from the optional in-progress capture.
|
||||||
|
fn status_of(live: Option<&Live>) -> StatsStatus {
|
||||||
|
match live {
|
||||||
|
Some(l) => StatsStatus {
|
||||||
|
armed: true,
|
||||||
|
sample_count: l.samples.len() as u32,
|
||||||
|
started_unix_ms: l.started_unix_ms,
|
||||||
|
kind: l.meta.as_ref().map(|m| m.kind.clone()).unwrap_or_default(),
|
||||||
|
},
|
||||||
|
None => StatsStatus {
|
||||||
|
armed: false,
|
||||||
|
sample_count: 0,
|
||||||
|
started_unix_ms: 0,
|
||||||
|
kind: String::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the `CaptureMeta` for an in-progress or finalizing capture (id derived from the start
|
||||||
|
/// time + negotiated mode; duration from the monotonic start).
|
||||||
|
fn meta_of(live: &Live) -> CaptureMeta {
|
||||||
|
let (kind, width, height, fps, codec, client) = match &live.meta {
|
||||||
|
Some(m) => (
|
||||||
|
m.kind.clone(),
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
m.fps,
|
||||||
|
m.codec.clone(),
|
||||||
|
m.client.clone(),
|
||||||
|
),
|
||||||
|
None => (String::new(), 0, 0, 0, String::new(), String::new()),
|
||||||
|
};
|
||||||
|
CaptureMeta {
|
||||||
|
id: capture_id(live.started_unix_ms, width, height),
|
||||||
|
started_unix_ms: live.started_unix_ms,
|
||||||
|
duration_ms: live.started.elapsed().as_millis() as u64,
|
||||||
|
kind,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
codec,
|
||||||
|
client,
|
||||||
|
sample_count: live.samples.len() as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir() -> PathBuf {
|
||||||
|
// A per-call unique dir: a process-wide counter (NOT a timestamp, which collides when tests
|
||||||
|
// run in parallel within the same millisecond — one test's cleanup would then wipe another's
|
||||||
|
// dir mid-run).
|
||||||
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let p = std::env::temp_dir().join(format!("pf-stats-{}-{}", std::process::id(), n));
|
||||||
|
let _ = std::fs::remove_dir_all(&p);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample() -> StatsSample {
|
||||||
|
StatsSample {
|
||||||
|
t_ms: 0,
|
||||||
|
session_id: 0,
|
||||||
|
stages: vec![StageTiming {
|
||||||
|
name: "capture".into(),
|
||||||
|
p50_us: 100.0,
|
||||||
|
p99_us: 200.0,
|
||||||
|
}],
|
||||||
|
fps: 60.0,
|
||||||
|
repeat_fps: 0.0,
|
||||||
|
mbps: 25.0,
|
||||||
|
bitrate_kbps: 20_000,
|
||||||
|
frames_dropped: 0,
|
||||||
|
packets_dropped: 0,
|
||||||
|
send_dropped: 0,
|
||||||
|
fec_recovered: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arm_record_save_load_delete() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let rec = StatsRecorder::new(dir.clone());
|
||||||
|
assert!(!rec.is_armed());
|
||||||
|
assert!(!rec.status().armed);
|
||||||
|
// A push while idle is a no-op (no live capture).
|
||||||
|
rec.push_sample(0, sample());
|
||||||
|
|
||||||
|
let st = rec.start();
|
||||||
|
assert!(st.armed);
|
||||||
|
assert!(rec.is_armed());
|
||||||
|
let sid = rec.register_session("native", 5120, 1440, 240, "hevc", "abcd");
|
||||||
|
rec.push_sample(sid, sample());
|
||||||
|
rec.push_sample(sid, sample());
|
||||||
|
assert_eq!(rec.status().sample_count, 2);
|
||||||
|
assert_eq!(rec.status().kind, "native");
|
||||||
|
assert!(rec.live_snapshot().is_some());
|
||||||
|
|
||||||
|
let meta = rec.stop().unwrap().expect("a capture was recording");
|
||||||
|
assert_eq!(meta.sample_count, 2);
|
||||||
|
assert_eq!(meta.kind, "native");
|
||||||
|
assert_eq!(meta.width, 5120);
|
||||||
|
assert!(meta.id.ends_with("_5120x1440"), "id was {}", meta.id);
|
||||||
|
assert!(!rec.is_armed());
|
||||||
|
assert!(rec.live_snapshot().is_none());
|
||||||
|
// Stop with nothing recording → Ok(None).
|
||||||
|
assert!(rec.stop().unwrap().is_none());
|
||||||
|
|
||||||
|
// It is listed and loadable.
|
||||||
|
let list = rec.list();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
assert_eq!(list[0].id, meta.id);
|
||||||
|
let loaded = rec.load(&meta.id).unwrap();
|
||||||
|
assert_eq!(loaded.samples.len(), 2);
|
||||||
|
assert_eq!(loaded.meta.codec, "hevc");
|
||||||
|
|
||||||
|
// Delete removes it; a second delete is NotFound.
|
||||||
|
rec.delete(&meta.id).unwrap();
|
||||||
|
assert!(rec.list().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
rec.delete(&meta.id).unwrap_err().kind(),
|
||||||
|
std::io::ErrorKind::NotFound
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_path_traversal_ids() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let rec = StatsRecorder::new(dir.clone());
|
||||||
|
for bad in [
|
||||||
|
"../secret",
|
||||||
|
"..",
|
||||||
|
".",
|
||||||
|
"a/b",
|
||||||
|
"a\\b",
|
||||||
|
"",
|
||||||
|
"/etc/passwd",
|
||||||
|
"x/../../y",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
rec.load(bad).unwrap_err().kind(),
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"load({bad:?}) must be rejected as NotFound"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rec.delete(bad).unwrap_err().kind(),
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"delete({bad:?}) must be rejected as NotFound"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn samples_are_bounded() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let rec = StatsRecorder::new(dir.clone());
|
||||||
|
rec.start();
|
||||||
|
for _ in 0..(MAX_SAMPLES + 50) {
|
||||||
|
rec.push_sample(0, sample());
|
||||||
|
}
|
||||||
|
assert_eq!(rec.status().sample_count as usize, MAX_SAMPLES);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_is_idempotent_while_armed() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let rec = StatsRecorder::new(dir.clone());
|
||||||
|
rec.start();
|
||||||
|
rec.register_session("native", 1920, 1080, 60, "hevc", "");
|
||||||
|
rec.push_sample(0, sample());
|
||||||
|
// A second start must NOT wipe the in-progress capture.
|
||||||
|
let st = rec.start();
|
||||||
|
assert!(st.armed);
|
||||||
|
assert_eq!(st.sample_count, 1);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
//! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture
|
//! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture
|
||||||
//! consumes the node via [`crate::capture::capture_virtual_output`].
|
//! consumes the node via [`crate::capture::capture_virtual_output`].
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
pub use punktfunk_core::Mode;
|
pub use punktfunk_core::Mode;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -225,6 +228,8 @@ pub fn compositor_for_kind(kind: ActiveKind) -> Option<Compositor> {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn default_runtime_dir() -> String {
|
fn default_runtime_dir() -> String {
|
||||||
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
||||||
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no
|
||||||
|
// memory — it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||||
let uid = unsafe { libc::getuid() };
|
let uid = unsafe { libc::getuid() };
|
||||||
format!("/run/user/{uid}")
|
format!("/run/user/{uid}")
|
||||||
})
|
})
|
||||||
@@ -245,6 +250,8 @@ fn default_bus(runtime: &str) -> String {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn detect_active_session() -> ActiveSession {
|
pub fn detect_active_session() -> ActiveSession {
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory —
|
||||||
|
// it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||||
let uid = unsafe { libc::getuid() };
|
let uid = unsafe { libc::getuid() };
|
||||||
let xdg_runtime_dir = default_runtime_dir();
|
let xdg_runtime_dir = default_runtime_dir();
|
||||||
let dbus = default_bus(&xdg_runtime_dir);
|
let dbus = default_bus(&xdg_runtime_dir);
|
||||||
@@ -479,7 +486,7 @@ pub fn apply_input_env(_chosen: Compositor) {}
|
|||||||
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
||||||
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||||
pub fn detect() -> Result<Compositor> {
|
pub fn detect() -> Result<Compositor> {
|
||||||
if let Ok(v) = std::env::var("PUNKTFUNK_COMPOSITOR") {
|
if let Some(v) = crate::config::config().compositor.as_deref() {
|
||||||
return match v.trim().to_ascii_lowercase().as_str() {
|
return match v.trim().to_ascii_lowercase().as_str() {
|
||||||
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin),
|
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin),
|
||||||
"wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots),
|
"wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots),
|
||||||
@@ -529,9 +536,15 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
// Windows has a single virtual-display backend (SudoVDA); the compositor arg is moot.
|
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
|
||||||
|
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
|
||||||
let _ = compositor;
|
let _ = compositor;
|
||||||
Ok(Box::new(sudovda::SudoVdaDisplay::new()?))
|
anyhow::ensure!(
|
||||||
|
pf_vdisplay::is_available(),
|
||||||
|
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
|
||||||
|
not loaded (the host installer bundles it; reinstall or check the driver state)"
|
||||||
|
);
|
||||||
|
Ok(Box::new(pf_vdisplay::PfVdisplayDisplay::new()?))
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
{
|
{
|
||||||
@@ -560,7 +573,7 @@ pub fn probe(compositor: Compositor) -> Result<()> {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let _ = compositor;
|
let _ = compositor;
|
||||||
sudovda::probe()
|
pf_vdisplay::probe()
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
{
|
{
|
||||||
@@ -601,15 +614,25 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
|||||||
std::sync::Arc::new(())
|
std::sync::Arc::new(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
|
||||||
|
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "vdisplay/linux/gamescope.rs"]
|
||||||
mod gamescope;
|
mod gamescope;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "vdisplay/linux/kwin.rs"]
|
||||||
mod kwin;
|
mod kwin;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "vdisplay/windows/manager.rs"]
|
||||||
|
pub(crate) mod manager;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "vdisplay/linux/mutter.rs"]
|
||||||
mod mutter;
|
mod mutter;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) mod sudovda;
|
#[path = "vdisplay/windows/pf_vdisplay.rs"]
|
||||||
|
pub(crate) mod pf_vdisplay;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "vdisplay/linux/wlroots.rs"]
|
||||||
mod wlroots;
|
mod wlroots;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user