Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca79f7f2d2 | |||
| 2262332150 | |||
| 71e3618f2e | |||
| 4563a0490c | |||
| ba39b08e09 | |||
| e1bc9fda22 | |||
| 12c7ec9e57 | |||
| 5a89a64920 | |||
| 4306d4f914 | |||
| 915f11a712 | |||
| fc35ea8c31 | |||
| 1e9a15699c | |||
| 6c2942ee45 | |||
| 188b26b584 | |||
| 83ee53290e | |||
| 0f798d62b6 | |||
| 080c55dbf7 | |||
| 1c04e77293 | |||
| e2d4c40167 | |||
| 580b1ea7a7 | |||
| 831b37b4b7 | |||
| 4f0b4aa68f | |||
| 963c406f33 | |||
| 7ab8acaf55 | |||
| c8e19396e4 | |||
| 78020cd66c | |||
| 8870e85233 | |||
| a81f1304cd | |||
| c75f39fd8e | |||
| 37c3e2bed2 | |||
| 4f40fa3cb7 | |||
| 486a292845 | |||
| d8c254281e | |||
| ae71e4628d | |||
| 01c55aed38 | |||
| 95308d352b | |||
| 9ff7d41bfe | |||
| 2b47d8cc28 | |||
| 7cd9364c9e | |||
| 3e498cd40d | |||
| 60de506f66 | |||
| 2865368771 | |||
| 6e2e946bc9 | |||
| b5f02000d6 | |||
| fe562f0562 | |||
| 4e00037a89 | |||
| 46b9aa8cf0 | |||
| 372b27540b | |||
| db4d15bf8b | |||
| 8e24ea9ed7 | |||
| 73c0125843 |
+20
-6
@@ -13,11 +13,25 @@
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# rsa "Marvin Attack" — a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle).
|
||||
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa
|
||||
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5
|
||||
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey /
|
||||
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets.
|
||||
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt.
|
||||
# rsa "Marvin Attack" (RUSTSEC-2023-0071): a timing side-channel in the rsa crate's variable-time
|
||||
# modular exponentiation of the SECRET exponent. IMPORTANT — this affects the RSA private-key op in
|
||||
# general, INCLUDING signing (m^d mod n), which the host DOES perform (gamestream/pairing.rs
|
||||
# `signing_key.sign(&serversecret)`). It is NOT, as an earlier version of this note wrongly claimed,
|
||||
# limited to decryption — so "the vulnerable path isn't exercised" is false; signing exercises it.
|
||||
# We accept it because the attack is not practically reachable here, NOT because the path is unused:
|
||||
# * No RSA decryption / PKCS#1v1.5 padding oracle exists anywhere (every `decrypt` in the tree is
|
||||
# AES/AES-GCM), so the classic Bleichenbacher/Marvin chosen-ciphertext oracle is absent.
|
||||
# * The only signed message (`serversecret`) is HOST-generated random, never attacker-chosen — so
|
||||
# there's no adaptive chosen-input probing (the lever remote RSA-timing key recovery needs); and
|
||||
# signing is gated behind the operator-entered pairing PIN, ONE signature per ceremony (a
|
||||
# repeated phase-3 is rejected — gamestream/pairing.rs — to deny a passive timing-sample harvester).
|
||||
# * GameStream is OFF by default (bare `serve` is native-only); the secure native QUIC plane uses
|
||||
# rustls' constant-time backend, NOT the rsa crate. RSA is touched only on the opt-in,
|
||||
# trusted-LAN GameStream/Moonlight pairing handshake. Moonlight mandates RSA-2048, so the
|
||||
# GameStream identity cannot move to Ed25519/ECDSA (only the native identity could, and it
|
||||
# already avoids the rsa crate).
|
||||
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream). Revisit if:
|
||||
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
|
||||
# this key, or any RSA decryption / key-transport using the private key is added.
|
||||
"RUSTSEC-2023-0071",
|
||||
]
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
*) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
*) VN="0.5.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
|
||||
+16
-13
@@ -36,8 +36,8 @@ jobs:
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||
# A main push -> 0.3.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base
|
||||
# A main push -> 0.5.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
|
||||
# below the eventual 0.5.0 tag, it climbs monotonically by run number, and the canary base
|
||||
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
|
||||
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
|
||||
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
*) V="0.5.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
@@ -87,12 +87,13 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
||||
|
||||
- name: Build + smoke-boot web console (node-server preset)
|
||||
# Gate the .deb on a real node boot: the punktfunk-web .deb runs `node .output/server`,
|
||||
# so prove the node-server build exists, isn't a bun bundle, and actually serves /login.
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
# (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login.
|
||||
# No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple.
|
||||
run: |
|
||||
# bun builds the console. It's baked into the rust-ci image, but bootstrap it here too so
|
||||
# the job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
||||
# bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
|
||||
# job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
||||
command -v bun >/dev/null || {
|
||||
apt-get install -y --no-install-recommends unzip
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
@@ -101,21 +102,23 @@ jobs:
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
if grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is a bun bundle (Bun.serve) — need the node-server preset"; exit 1
|
||||
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||
fi
|
||||
PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci node .output/server/index.mjs &
|
||||
PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci bun .output/server/index.mjs &
|
||||
NP=$!; sleep 3
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3009/login || echo 000)
|
||||
kill "$NP" 2>/dev/null || true
|
||||
echo "web console smoke: /login -> $code"
|
||||
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under node"; exit 1; }
|
||||
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; }
|
||||
|
||||
- name: Build .debs
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
||||
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
||||
VERSION="$VERSION" bash packaging/debian/build-web-deb.sh
|
||||
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Version + channel
|
||||
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
|
||||
# 0.3.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
||||
# 0.5.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
|
||||
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
|
||||
# on a stable box never jumps to a canary build. The generic-registry version string allows
|
||||
# letters/dots/hyphens.
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
|
||||
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||
*) V="0.5.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||
*) V="0.3.0" ;; # canary marketing version; the build number disambiguates
|
||||
*) V="0.5.0" ;; # canary marketing version; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
@@ -207,10 +207,20 @@ jobs:
|
||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Separate archive from the Developer ID one above: App Store needs a profile-signed
|
||||
# archive (manual signing), not the unsigned-then-codesign DMG path. Same App-Manager
|
||||
# ASC-key constraint as iOS/tvOS — MANUAL signing, NOT -allowProvisioningUpdates
|
||||
# (cloud signing the key can't do). Quit Xcode so it can't prune the dropped profile.
|
||||
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
|
||||
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
|
||||
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
|
||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
|
||||
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
|
||||
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
|
||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||
# distribution profile that export needs.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk macOS App Store Distribution"
|
||||
@@ -218,11 +228,10 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="$PROFILE"
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -252,35 +261,27 @@ jobs:
|
||||
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# MANUAL App Store signing: the local (valid) Apple Distribution identity + the App
|
||||
# Store provisioning profile. NOT -allowProvisioningUpdates — with an App-Manager-role
|
||||
# ASC key that forces Xcode's CLOUD-managed signing, which the role can't do ("Cloud
|
||||
# signing permission error"). The profile must be installed on the runner under
|
||||
# ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ (install it once with
|
||||
# Xcode.app quit, or it prunes the manually-dropped distribution profile).
|
||||
# A running Xcode.app prunes unrecognized profiles from that dir — quit it so the App
|
||||
# Store profile survives this build; headless xcodebuild doesn't need the GUI app.
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
|
||||
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
||||
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
|
||||
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
|
||||
# manually-installed App Store distribution profile survives for export.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
# Scope signing to the iOS device SDK via an xcconfig — see the tvOS step below for the
|
||||
# full rationale. A global (CLI) profile specifier would also be forced onto the shared
|
||||
# macOS-host SwiftPM macro plugins, which reject it and fail the archive; [sdk=iphoneos*]
|
||||
# in an xcconfig lands it on the app/framework slices only.
|
||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-ios.xcconfig"
|
||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
||||
CODE_SIGN_STYLE = Manual
|
||||
DEVELOPMENT_TEAM = $TEAM_ID
|
||||
CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Distribution
|
||||
PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*] = $PROFILE
|
||||
XCCONF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-xcconfig "$SIGN_XCCONFIG" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -312,33 +313,24 @@ jobs:
|
||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Same manual App Store signing as iOS (the App-Manager ASC key can't cloud-sign).
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
|
||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
|
||||
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
|
||||
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||
# Scope signing to the tvOS device SDK via an xcconfig. A global (CLI) profile specifier
|
||||
# hits EVERY target, including the shared SwiftPM macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) which build for the macOS host and reject a provisioning profile
|
||||
# ("<macro> does not support provisioning profiles"), failing the archive. Conditionals
|
||||
# work only in an xcconfig (xcodebuild mis-parses a CLI "SETTING[sdk=..]=val"), and a
|
||||
# command-line -xcconfig outranks target settings, so [sdk=appletvos*] puts the profile on
|
||||
# the app/framework slices only — the macosx-host macros get nothing. (The macOS archive
|
||||
# above is immune: its host-SDK macros are CODE_SIGNING_ALLOWED=NO, so a global specifier
|
||||
# is ignored there.)
|
||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-tvos.xcconfig"
|
||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
||||
CODE_SIGN_STYLE = Manual
|
||||
DEVELOPMENT_TEAM = $TEAM_ID
|
||||
CODE_SIGN_IDENTITY[sdk=appletvos*] = Apple Distribution
|
||||
PROVISIONING_PROFILE_SPECIFIER[sdk=appletvos*] = $PROFILE
|
||||
XCCONF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-xcconfig "$SIGN_XCCONFIG" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
|
||||
@@ -68,8 +68,8 @@ jobs:
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.3.0-0.ciN.g<sha>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.5.0-0.ciN.g<sha>
|
||||
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.5.0-1 yet
|
||||
# climbs by run number. The canary base stays one minor ahead of the latest stable so a
|
||||
# stable->canary box re-point still moves forward. The spec %build stamps
|
||||
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
|
||||
*) V="0.3.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||
*) V="0.5.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
|
||||
esac
|
||||
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -171,8 +171,8 @@ jobs:
|
||||
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"
|
||||
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
|
||||
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
|
||||
}
|
||||
Pop-Location
|
||||
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
|
||||
|
||||
@@ -144,11 +144,25 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||
includes the pairing ceremony + `--require-pairing` gate),
|
||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter**
|
||||
(`VTDecompressionSession` + `CAMetalLayer`) is built and live-validated on glass behind the opt-in
|
||||
`punktfunk.presenter` flag (~11 ms p50 capture→present), to become the default after a few
|
||||
resolution/HDR checks. Next: make stage 2 the default, glass-to-glass numbers via
|
||||
`tools/latency-probe`, iOS/iPadOS/tvOS variants.
|
||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter is now the DEFAULT**
|
||||
(stage-1 is the Metal-unavailable / DEBUG fallback): explicit `VTDecompressionSession` decode →
|
||||
`CAMetalLayer`, presented from the hosting view's **main-runloop `CADisplayLink`** (`renderTick` pops
|
||||
the newest ready frame per vsync; macOS `displaySyncEnabled = false` is the real fullscreen-judder fix,
|
||||
~11 ms p50). *(An off-main `CAMetalDisplayLink` and an off-main blocking-render present thread were
|
||||
both tried and reverted — both measured slower on macOS and iPad.)* **HDR fixed**
|
||||
(`design/apple-stage2-presenter.md`): the "too bright" bug was a missing reference-white anchor — the
|
||||
fix keeps the PQ-passthrough shader and adds `CAEDRMetadata.hdr10(…, opticalOutputScale: 203)` +
|
||||
`wantsExtendedDynamicRangeContent` on the layer (on all platforms; the old `#if os(macOS)` guard left
|
||||
iOS/tvOS EDR half-engaged), routing the 0xCE mastering metadata to the layer (via `setHdrMeta`) instead
|
||||
of a never-composited source buffer. **Mid-session SDR↔HDR** is handled: `render` reconciles the layer
|
||||
per-frame from the decoded `frame.isHDR` (per-mode pixel format `bgra8`/`rgba16Float`), so a game
|
||||
entering HDR mid-stream just reconfigures (last 0xCE grade cached + re-applied; pump drains 0xCE
|
||||
unconditionally). **4:4:4 added**: decode format is a 2×2 `(chroma, HDR)` matrix
|
||||
(`420v/x420/444v/x444`, all biplanar so the shaders are unchanged), advertised (`VIDEO_CAP_444`) only
|
||||
behind a **hardware-required `VTDecompressionSession` probe** (`Stage444Probe`, validated on M3) with a
|
||||
Settings opt-out + a bounded pump backstop for an undecodable 4:4:4 session. *HDR brightness + 4:4:4
|
||||
still need on-glass validation (Windows-HDR / `PUNKTFUNK_444` host).* Next: glass-to-glass numbers via
|
||||
`tools/latency-probe`.
|
||||
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||
|
||||
Generated
+32
-8
@@ -1995,7 +1995,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2127,7 +2127,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2331,6 +2331,17 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -2709,7 +2720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2723,7 +2734,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2743,7 +2754,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2763,7 +2774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2793,7 +2804,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -2839,12 +2850,14 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"usbip-sim",
|
||||
"utoipa",
|
||||
"utoipa-axum",
|
||||
"utoipa-scalar",
|
||||
"wasapi",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-misc",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
@@ -2857,7 +2870,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -4236,6 +4249,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
"crates/pf-driver-proto",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
@@ -11,9 +12,11 @@ members = [
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
]
|
||||
# Standalone PoC (built on its own; pulls usbip/tokio/libusb we don't want in the workspace).
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+9
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.0.1"
|
||||
"version": "0.4.1"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1354,6 +1354,14 @@
|
||||
"type": "object",
|
||||
"description": "Arm-native-pairing request body.",
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
|
||||
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
},
|
||||
"ttl_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
|
||||
@@ -16,8 +16,9 @@ RUN dnf -y install \
|
||||
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \
|
||||
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
|
||||
&& dnf -y install \
|
||||
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache)
|
||||
# AND the punktfunk-web .output at runtime; unzip is for the bun installer below.
|
||||
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
|
||||
# — the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun
|
||||
# installer.
|
||||
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
|
||||
# build toolchain + bindgen
|
||||
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
||||
@@ -28,9 +29,10 @@ RUN dnf -y install \
|
||||
gtk4-devel libadwaita-devel SDL3-devel \
|
||||
&& dnf clean all
|
||||
|
||||
# bun — the build tool for the punktfunk-web console (`bun run build` -> the node-server .output
|
||||
# the punktfunk-web RPM ships and runs with plain node). Not in Fedora repos; install the official
|
||||
# standalone binary to a system PATH dir so the rpmbuild `%build` (run as any uid) finds it.
|
||||
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
|
||||
# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
|
||||
# RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a
|
||||
# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it.
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& bun --version
|
||||
|
||||
@@ -175,9 +175,9 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
status = "Connecting to $targetHost:$targetPort…"
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
// Advertise HDR only when this device's display can present it (else the host sends a
|
||||
// proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = displaySupportsHdr(context)
|
||||
// Advertise HDR only when the user enabled it AND this device's display can present it
|
||||
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||
// explicit choice is passed through unchanged.
|
||||
@@ -224,7 +224,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
status = null
|
||||
discovery.stop() // free the Wi-Fi radio before the (parked) stream session
|
||||
scope.launch {
|
||||
val hdrEnabled = displaySupportsHdr(context)
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
// Pin the advertised fingerprint for a discovered host (defence against an impostor while
|
||||
// we wait); a manually-typed host has none, so trust-on-first-use.
|
||||
|
||||
@@ -14,6 +14,13 @@ data class Settings(
|
||||
val height: Int = 0,
|
||||
val hz: Int = 0,
|
||||
val bitrateKbps: Int = 0,
|
||||
/**
|
||||
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
|
||||
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
|
||||
* advertised regardless, so the host sends a proper 8-bit BT.709 stream rather than PQ the panel
|
||||
* would mis-tone-map. Turning this off forces SDR even on a capable panel.
|
||||
*/
|
||||
val hdrEnabled: Boolean = true,
|
||||
val compositor: Int = 0,
|
||||
val gamepad: Int = 0,
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
@@ -40,6 +47,7 @@ class SettingsStore(context: Context) {
|
||||
height = prefs.getInt(K_H, 0),
|
||||
hz = prefs.getInt(K_HZ, 0),
|
||||
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
@@ -54,6 +62,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_H, s.height)
|
||||
.putInt(K_HZ, s.hz)
|
||||
.putInt(K_BITRATE, s.bitrateKbps)
|
||||
.putBoolean(K_HDR, s.hdrEnabled)
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
@@ -68,6 +77,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_H = "height"
|
||||
const val K_HZ = "hz"
|
||||
const val K_BITRATE = "bitrate_kbps"
|
||||
const val K_HDR = "hdr_enabled"
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
|
||||
@@ -94,6 +94,22 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
options = BITRATE_OPTIONS,
|
||||
selected = s.bitrateKbps,
|
||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||
|
||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
|
||||
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
|
||||
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
|
||||
val hdrCapable = remember { displaySupportsHdr(context) }
|
||||
ToggleRow(
|
||||
title = "HDR",
|
||||
subtitle = if (hdrCapable) {
|
||||
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
|
||||
} else {
|
||||
"This display can't present HDR10 — streams stay SDR"
|
||||
},
|
||||
checked = s.hdrEnabled && hdrCapable,
|
||||
enabled = hdrCapable,
|
||||
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("Host") {
|
||||
@@ -181,24 +197,31 @@ private fun SettingsGroup(title: String, content: @Composable ColumnScope.() ->
|
||||
}
|
||||
}
|
||||
|
||||
/** A title + subtitle on the left, a Switch on the right. */
|
||||
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
|
||||
@Composable
|
||||
private fun ToggleRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
// Dim the labels when disabled so the row reads as inactive (the Switch dims itself).
|
||||
val labelAlpha = if (enabled) 1f else 0.38f
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = labelAlpha),
|
||||
)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = labelAlpha),
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
@@ -102,6 +103,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
||||
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
||||
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
|
||||
// The prior request is captured and restored on the way out.
|
||||
val priorOrientation = activity?.requestedOrientation
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||
@@ -114,6 +122,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.streamHandle = 0L
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
// Release the landscape lock so the rest of the app follows the device/system again.
|
||||
activity?.requestedOrientation =
|
||||
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
NativeBridge.nativeStopAudio(handle)
|
||||
@@ -314,9 +325,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The live stats overlay — mirrors the Apple client's HUD. Reads the 10-double layout from
|
||||
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
|
||||
* [NativeBridge.nativeVideoStats]:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped]`.
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
|
||||
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
@@ -338,6 +351,14 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
videoFeedLine(s)?.let { feed ->
|
||||
Text(
|
||||
feed,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
if (latValid) {
|
||||
val tag = if (skew) "" else " (same-host)"
|
||||
Text(
|
||||
@@ -357,3 +378,31 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
|
||||
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
|
||||
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
|
||||
* Android decoder is always HEVC (`video/hevc`).
|
||||
*/
|
||||
private fun videoFeedLine(s: DoubleArray): String? {
|
||||
if (s.size < 14) return null
|
||||
val bitDepth = s[10].toInt()
|
||||
val primaries = s[11].toInt()
|
||||
val transfer = s[12].toInt()
|
||||
val chromaIdc = s[13].toInt()
|
||||
val depthLabel = if (bitDepth > 0) "$bitDepth-bit" else "8-bit"
|
||||
val (dynamicRange, colorSpace) = when (transfer) {
|
||||
16 -> "HDR" to "BT.2020 PQ"
|
||||
18 -> "HDR" to "BT.2020 HLG"
|
||||
else -> "SDR" to if (primaries == 9) "BT.2020" else "BT.709"
|
||||
}
|
||||
val chromaLabel = when (chromaIdc) {
|
||||
3 -> "4:4:4"
|
||||
2 -> "4:2:2"
|
||||
else -> "4:2:0"
|
||||
}
|
||||
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
|
||||
}
|
||||
|
||||
@@ -186,9 +186,11 @@ internal fun StreamScene() {
|
||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||
),
|
||||
) {
|
||||
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped]
|
||||
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
|
||||
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
|
||||
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
|
||||
StatsOverlay(
|
||||
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0),
|
||||
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
|
||||
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,15 +50,25 @@ object Gamepad {
|
||||
const val PREF_DUALSENSE = 2
|
||||
const val PREF_XBOXONE = 3
|
||||
const val PREF_DUALSHOCK4 = 4
|
||||
const val PREF_STEAMCONTROLLER = 5
|
||||
const val PREF_STEAMDECK = 6
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
private const val VID_MICROSOFT = 0x045E
|
||||
private const val VID_VALVE = 0x28DE
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||
|
||||
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
|
||||
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
|
||||
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
|
||||
// buttons + sticks reach the host for now — parity with the desktop type resolution.
|
||||
private val PID_STEAMDECK = setOf(0x1205)
|
||||
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
private val PID_XBOXONE = setOf(
|
||||
@@ -82,6 +92,8 @@ object Gamepad {
|
||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
||||
else -> PREF_XBOX360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +103,12 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||
* Returns 10 doubles:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
|
||||
* (the two flags are 1.0/0.0). Poll ~1 Hz; each call resets the measurement window.
|
||||
* Returns 14 doubles:
|
||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||
* (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — bit depth
|
||||
* 8/10, CICP primaries/transfer, and the HEVC chroma_format_idc 1=4:2:0 / 3=4:4:4). Poll ~1 Hz;
|
||||
* each call resets the measurement window.
|
||||
*/
|
||||
external fun nativeVideoStats(handle: Long): DoubleArray?
|
||||
|
||||
|
||||
@@ -114,6 +114,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[2..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
HidOutput::TrackpadHaptic { .. } => {
|
||||
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
||||
// rumble already rides the universal 0xCA plane).
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
|
||||
@@ -409,11 +409,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
||||
/// Returns 10 doubles
|
||||
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
|
||||
/// (the two flags are 1.0/0.0), or `null` when no decode thread is running. Poll ~1 Hz from the UI;
|
||||
/// each call resets the measurement window. Not android-gated — pure `jni` + connector reads, so it
|
||||
/// links on the host build too (Kotlin only ever calls it on device).
|
||||
/// Returns 14 doubles
|
||||
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||
/// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), or
|
||||
/// `null` when no decode thread is running. Poll ~1 Hz from the UI; each call resets the measurement
|
||||
/// window. Not android-gated — pure `jni` + connector reads, so it links on the host build too
|
||||
/// (Kotlin only ever calls it on device).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
env: JNIEnv,
|
||||
@@ -431,7 +433,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
None => return std::ptr::null_mut(), // not streaming → no stats
|
||||
};
|
||||
let mode = h.client.mode();
|
||||
let buf: [f64; 10] = [
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 14] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.lat_p50_ms,
|
||||
@@ -442,6 +445,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
mode.height as f64,
|
||||
mode.refresh_hz as f64,
|
||||
h.client.frames_dropped() as f64,
|
||||
// Video-feed properties the host resolved at the handshake (Welcome): encode bit depth
|
||||
// (8 / 10), the CICP colour primaries + transfer code points (Kotlin maps these to a
|
||||
// colour-space / HDR label — transfer 16 = PQ, 18 = HLG ⇒ HDR), and the HEVC
|
||||
// chroma_format_idc (1 = 4:2:0, 3 = 4:4:4). Static for the session unless renegotiated.
|
||||
h.client.bit_depth as f64,
|
||||
color.primaries as f64,
|
||||
color.transfer as f64,
|
||||
h.client.chroma_format as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
|
||||
@@ -24,6 +24,9 @@ let package = Package(
|
||||
.copy("Resources/THIRD-PARTY-NOTICES.txt"),
|
||||
.copy("Resources/LICENSE-MIT.txt"),
|
||||
.copy("Resources/LICENSE-APACHE.txt"),
|
||||
// Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website.
|
||||
// Registered with Core Text at first use; see BrandFont.swift.
|
||||
.copy("Resources/Fonts"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
|
||||
@@ -364,7 +364,7 @@
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
@@ -398,7 +398,7 @@
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
@@ -429,7 +429,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -468,7 +468,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -506,7 +506,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -536,7 +536,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
|
||||
@@ -10,32 +10,59 @@ struct AcknowledgementsView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.title2).bold()
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.caption)
|
||||
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
|
||||
// ~885 KB total) load lazily as they scroll into view — a single Text that large overshoots
|
||||
// the text-rendering height limit (blank below the limit + very slow). spacing 0 keeps the
|
||||
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.geist(22, .bold, relativeTo: .title2))
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
.font(.caption.monospaced())
|
||||
.modifier(SelectableText())
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Bundled font")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text("punktfunk ships the Geist typeface (Geist Sans), "
|
||||
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
||||
+ "License 1.1.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
if !Licenses.fontLicense.isEmpty {
|
||||
Text(Licenses.fontLicense)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
.font(.caption.monospaced())
|
||||
.modifier(SelectableText())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(Licenses.thirdPartyNotices)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
ForEach(Licenses.thirdPartyNoticesChunks.indices, id: \.self) { i in
|
||||
Text(Licenses.thirdPartyNoticesChunks[i])
|
||||
.font(.caption2.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 900, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -81,6 +81,11 @@ struct AddHostSheet: View {
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The detent below is sized to fit all 3 rows + the action button exactly, so the
|
||||
// Form must NOT scroll/bounce inside it — lock it. (iOS 16+; safe at iOS 17.)
|
||||
.scrollDisabled(true)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
||||
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
||||
@@ -120,8 +125,8 @@ struct AddHostSheet: View {
|
||||
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
||||
// at. A single fixed detent is enough: the system keeps the content above the keyboard
|
||||
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
|
||||
// centered formSheet card). If Dynamic Type grows the rows past this height the Form just
|
||||
// scrolls inside the detent — nothing is clipped. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||
// centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
|
||||
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||
.presentationDetents([.height(320)])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// App-wide brand chrome. SwiftUI has no single switch to put a custom font on every navigation
|
||||
// title, so the iOS large/inline nav titles are themed through UINavigationBar's appearance proxy
|
||||
// (set once at launch). Backgrounds are left at the system defaults — transparent at the scroll
|
||||
// edge (the large title floats on the content), blurred once scrolled — so only the typeface
|
||||
// changes: Geist, matching the cards and the website.
|
||||
|
||||
#if os(iOS)
|
||||
import PunktfunkKit
|
||||
import UIKit
|
||||
|
||||
enum BrandTheme {
|
||||
static func apply() {
|
||||
BrandFont.registerIfNeeded()
|
||||
|
||||
let scrollEdge = UINavigationBarAppearance()
|
||||
scrollEdge.configureWithTransparentBackground()
|
||||
applyFonts(to: scrollEdge)
|
||||
|
||||
let standard = UINavigationBarAppearance()
|
||||
standard.configureWithDefaultBackground()
|
||||
applyFonts(to: standard)
|
||||
|
||||
let proxy = UINavigationBar.appearance()
|
||||
proxy.scrollEdgeAppearance = scrollEdge
|
||||
proxy.standardAppearance = standard
|
||||
proxy.compactAppearance = standard
|
||||
}
|
||||
|
||||
/// Override only the title fonts; leave colors/backgrounds at the configured defaults.
|
||||
private static func applyFonts(to appearance: UINavigationBarAppearance) {
|
||||
if let large = UIFont(name: "Geist-Bold", size: 34) {
|
||||
appearance.largeTitleTextAttributes[.font] = large
|
||||
}
|
||||
if let inline = UIFont(name: "Geist-SemiBold", size: 17) {
|
||||
appearance.titleTextAttributes[.font] = inline
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -28,6 +28,7 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@@ -68,15 +69,19 @@ struct ContentView: View {
|
||||
// A session actually started — remember it on the card ("Connected … ago"
|
||||
// plus the accent ring on the most recent host).
|
||||
guard let host = model.activeHost else { break }
|
||||
store.markConnected(host.id)
|
||||
// Delegated approval just succeeded: the operator let this device in, so pin the
|
||||
// host's observed fingerprint and remember it as paired — future connects are then
|
||||
// silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
|
||||
if awaitingApproval?.host.id == host.id {
|
||||
if let fp = model.connection?.hostFingerprint {
|
||||
store.pin(host.id, fingerprint: fp)
|
||||
}
|
||||
awaitingApproval = nil
|
||||
let approvedFingerprint = awaitingApproval?.host.id == host.id
|
||||
? model.connection?.hostFingerprint : nil
|
||||
if awaitingApproval?.host.id == host.id { awaitingApproval = nil }
|
||||
// Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
|
||||
// its @Published from inside .onChange (a view-update callback) trips SwiftUI's
|
||||
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
|
||||
let store = store
|
||||
DispatchQueue.main.async {
|
||||
store.markConnected(host.id)
|
||||
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
|
||||
}
|
||||
case .idle:
|
||||
// The delegated-approval connect failed, timed out, or was cancelled — drop the
|
||||
@@ -333,6 +338,7 @@ struct ContentView: View {
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
launchID: launchID,
|
||||
allowTofu: allowTofu,
|
||||
requestAccess: requestAccess)
|
||||
@@ -475,6 +481,7 @@ struct ContentView: View {
|
||||
gamepad: pad,
|
||||
bitrateKbps: bitrate,
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
autoTrust: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ struct ControllerTestView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Test Controller").font(.headline)
|
||||
Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
@@ -99,8 +99,8 @@ struct ControllerTestView: View {
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.headline)
|
||||
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
||||
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -209,7 +209,7 @@ struct ControllerTestView: View {
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
fingerDot(tp.primary, color: .accentColor)
|
||||
@@ -230,7 +230,7 @@ struct ControllerTestView: View {
|
||||
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("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
.font(.caption2.monospaced())
|
||||
@@ -254,11 +254,11 @@ struct ControllerTestView: View {
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .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)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||
@@ -289,11 +289,11 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Adaptive triggers need a DualSense.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +348,7 @@ struct ControllerTestView: View {
|
||||
_ title: String, @ViewBuilder _ content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -127,14 +127,13 @@ struct HomeView: View {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
#if os(iOS)
|
||||
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
||||
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
||||
// a stack (double title bars). `settingsSheetSizing()` widens the sheet on iPad for the
|
||||
// two-column layout.
|
||||
.sheet(isPresented: $showSettings) {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
Button("Done") { showSettings = false }
|
||||
}
|
||||
}
|
||||
SettingsView()
|
||||
.settingsSheetSizing()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -172,7 +171,7 @@ struct HomeView: View {
|
||||
private var discoveredSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.headline)
|
||||
.font(.geist(15, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
@@ -249,8 +248,10 @@ struct HomeView: View {
|
||||
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
|
||||
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
||||
private var gridColumns: [GridItem] {
|
||||
// Wider than before: the monogram card is a horizontal module (tile + address line), so
|
||||
// it needs room for a monospaced "IP:port" without truncating.
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
||||
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
#else
|
||||
|
||||
@@ -1,26 +1,75 @@
|
||||
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
||||
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
||||
// host (tap to save + connect). Both share the "monogram module" look — a squared brand-purple
|
||||
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
|
||||
// (address, status), framed by a hairline panel border. Industrial, not soft.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
|
||||
private struct CardMetrics {
|
||||
let iconSize: CGFloat
|
||||
let iconBox: CGFloat
|
||||
let cardPadding: CGFloat
|
||||
let nameFont: Font
|
||||
let tile: CGFloat // monogram tile side
|
||||
let monogram: CGFloat // monogram letter point size
|
||||
let name: CGFloat // host-name point size
|
||||
let meta: CGFloat // address (mono) point size
|
||||
let status: CGFloat // status-label (mono) point size
|
||||
let padding: CGFloat
|
||||
let spacing: CGFloat // tile ↔ text gap
|
||||
let radius: CGFloat
|
||||
|
||||
static var current: CardMetrics {
|
||||
#if os(iOS)
|
||||
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||
padding: 16, spacing: 14, radius: 12)
|
||||
#elseif os(tvOS)
|
||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
||||
padding: 18, spacing: 18, radius: 14)
|
||||
#else
|
||||
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||
padding: 13, spacing: 12, radius: 10)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
||||
/// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet.
|
||||
private func monogram(_ name: String) -> String {
|
||||
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||
return String(first).uppercased()
|
||||
}
|
||||
|
||||
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
|
||||
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
||||
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.72)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(Color.brand.opacity(0.14)))
|
||||
if connecting {
|
||||
ProgressView().tint(filled ? .white : Color.brand)
|
||||
} else {
|
||||
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
|
||||
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
|
||||
// the clip below are belt-and-suspenders for an unusually wide glyph.
|
||||
Text(letter)
|
||||
.font(.geistFixed(m.monogram, .bold))
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(filled ? Color.white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: m.tile, height: m.tile)
|
||||
.clipShape(shape)
|
||||
.overlay {
|
||||
if !filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
|
||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||
struct HostCardView: View {
|
||||
let host: StoredHost
|
||||
@@ -41,66 +90,44 @@ struct HostCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
ZStack {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isConnecting ? 0.3 : 1)
|
||||
if isConnecting {
|
||||
ProgressView()
|
||||
}
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.displayName)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
statusRow(m)
|
||||
}
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
||||
Circle()
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
||||
.frame(width: 7, height: 7)
|
||||
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
||||
Text(host.displayName)
|
||||
.font(m.nameFont)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
if host.pinnedSHA256 != nil {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let last = host.lastConnected {
|
||||
Text("Connected \(last, format: .relative(presentation: .named))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
||||
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
||||
// and a brand accent bar down the leading edge for the most-recent host.
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .leading) {
|
||||
if isMostRecent {
|
||||
Rectangle().fill(Color.brand).frame(width: 3)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
||||
// Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content
|
||||
// tiles (it flattens hierarchy over an opaque grid) — see GlassStyle.swift.
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
if isMostRecent {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
||||
}
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
@@ -119,10 +146,31 @@ struct HostCardView: View {
|
||||
Button("Remove", role: .destructive, action: onRemove)
|
||||
}
|
||||
}
|
||||
|
||||
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
|
||||
/// certificate is pinned (the lock state, spelled out).
|
||||
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 6, height: 6)
|
||||
// The state is spelled out in the adjacent text, so the pip is decorative —
|
||||
// otherwise VoiceOver reads the status twice ("Online, ONLINE …").
|
||||
.accessibilityHidden(true)
|
||||
Text(isOnline ? "ONLINE" : "OFFLINE")
|
||||
if host.pinnedSHA256 != nil {
|
||||
Text("· PAIRED")
|
||||
}
|
||||
}
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
||||
/// tapping saves it and connects (or pairs, if the host requires it).
|
||||
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||
struct DiscoveredCardView: View {
|
||||
let discovered: DiscoveredHost
|
||||
let isBusy: Bool
|
||||
@@ -131,47 +179,77 @@ struct DiscoveredCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(discovered.name)
|
||||
.font(m.nameFont)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: discovered.requiresPairing
|
||||
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: m.status))
|
||||
.accessibilityHidden(true) // decorative; the adjacent text says the state
|
||||
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
|
||||
}
|
||||
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.secondary.opacity(0.25),
|
||||
Color.secondary.opacity(0.3),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The iOS host-card press/hover treatment, one style for both idioms:
|
||||
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
|
||||
/// inert without a pointer.)
|
||||
/// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the
|
||||
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
|
||||
/// press scale doubles as click feedback.)
|
||||
struct HostCardButtonStyle: ButtonStyle {
|
||||
var cornerRadius: CGFloat
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
|
||||
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
|
||||
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.hoverEffect(.highlight)
|
||||
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
|
||||
// hardware on iPad → silently ignored there.
|
||||
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
|
||||
pressed ? .impact(weight: .light) : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -146,7 +146,7 @@ private struct GameCard: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { storeBadge }
|
||||
Text(game.title)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ private struct GameCard: View {
|
||||
|
||||
private var storeBadge: some View {
|
||||
Text(game.isCustom ? "Custom" : "Steam")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
@@ -193,7 +193,7 @@ private struct PosterImage: View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
|
||||
@@ -48,7 +48,7 @@ struct PairSheet: View {
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||
+ "needed.")
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
TVFieldRow(
|
||||
@@ -59,7 +59,7 @@ struct PairSheet: View {
|
||||
) { editing = .clientName }
|
||||
if let errorText {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
HStack(spacing: 32) {
|
||||
@@ -121,13 +121,13 @@ struct PairSheet: View {
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint "
|
||||
+ "comparison needed.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,36 @@ struct PunktfunkClientApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
#endif
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
// Put Geist on the navigation titles before any bar is built.
|
||||
BrandTheme.apply()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
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 {
|
||||
// Pin the whole app's tint to the brand purple explicitly — the asset-catalog accent
|
||||
// resolution is environment/timing-sensitive and can fall back to system blue. Wraps the
|
||||
// screenshot harness too, so captured screens are on-brand.
|
||||
Group {
|
||||
#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()
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
.tint(.brand)
|
||||
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
||||
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
||||
.font(.geist(17, relativeTo: .body))
|
||||
}
|
||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||
@@ -34,7 +50,10 @@ struct PunktfunkClientApp: App {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
Settings {
|
||||
// A separate scene — `.tint` does not cross scene boundaries, so re-apply the brand
|
||||
// tint here or the Preferences window falls back to the (unreliable) asset accent.
|
||||
SettingsView()
|
||||
.tint(.brand)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -103,11 +103,11 @@ private struct ShotSettings: View {
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||
// the General page) instead of the bare category list.
|
||||
SettingsView(initialCategory: .general)
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
@@ -175,10 +175,10 @@ private struct ShotHUD: View {
|
||||
.foregroundStyle(.secondary)
|
||||
#if os(macOS)
|
||||
Text("⌘⎋ releases the mouse")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
#elseif os(tvOS)
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
@@ -259,7 +259,7 @@ private struct ShotDesktopFrame: View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
Text("Streaming from Battlestation")
|
||||
.font(.system(.callout, weight: .semibold))
|
||||
.font(.geist(16, .semibold, relativeTo: .callout))
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||
.glassBackground(Capsule())
|
||||
|
||||
@@ -129,6 +129,8 @@ final class SessionModel: ObservableObject {
|
||||
#endif
|
||||
}()
|
||||
let hdrCapable = hdrEnabled && displayHDR
|
||||
// 4:4:4 opt-out (default on); the hardware-decode probe below is the real gate.
|
||||
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? true
|
||||
Task.detached(priority: .userInitiated) {
|
||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||
// actor. The persistent identity is presented on every connect so a paired
|
||||
@@ -138,9 +140,21 @@ final class SessionModel: ObservableObject {
|
||||
// Advertise 10-bit + HDR10 when enabled: the host upgrades to a BT.2020 PQ Main10 stream
|
||||
// only for actual HDR content (its own gate); the VideoToolbox/Metal present path is
|
||||
// HDR-capable (P010 + itur_2100_PQ + EDR). 0 keeps the 8-bit BT.709 SDR stream.
|
||||
let videoCaps: UInt8 = hdrCapable
|
||||
var videoCaps: UInt8 = hdrCapable
|
||||
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
|
||||
: 0
|
||||
// Advertise full-chroma 4:4:4 only when allowed AND this device can HARDWARE-decode it
|
||||
// (software 4:4:4 is too slow for real-time). The host content-gates depth, so an
|
||||
// HDR-advertised session can still receive an 8-bit 4:4:4 stream (SDR content) — require
|
||||
// BOTH depths there. Otherwise a no-op (the host emits 4:4:4 only if it too opted in);
|
||||
// `chromaFormat` on the connection reflects what was actually resolved.
|
||||
let canDecode444 =
|
||||
hdrCapable
|
||||
? (Stage444Probe.hwDecode444_8bit && Stage444Probe.hwDecode444_10bit)
|
||||
: Stage444Probe.hwDecode444_8bit
|
||||
if want444, canDecode444 {
|
||||
videoCaps |= PunktfunkConnection.videoCap444
|
||||
}
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
width: width, height: height, refreshHz: hz,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||
// there is no scaling anywhere in the pipeline.
|
||||
//
|
||||
// Navigation differs per platform: macOS uses a tabbed preferences window (the sections had
|
||||
// outgrown one scrolling pane); iOS uses a single grouped Form; tvOS uses a focus-native
|
||||
// pushed-picker layout. The individual sections (`streamModeSection`, `audioSection`, …) are
|
||||
// shared across all three so a setting is defined exactly once.
|
||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||
// `audioSection`, …) are shared across all three so a setting is defined exactly once.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -21,7 +23,9 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage2"
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@@ -32,6 +36,22 @@ struct SettingsView: View {
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State private var showControllerTest = false
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.pointerCapture) private var pointerCapture = true
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var settingsSelection: SettingsCategory?
|
||||
// Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen
|
||||
// — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts
|
||||
// .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible.
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
|
||||
// Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a
|
||||
// preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even
|
||||
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
|
||||
@State private var customMode = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||
@@ -39,6 +59,15 @@ struct SettingsView: View {
|
||||
@State private var inputDevices: [AudioDevice] = []
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on
|
||||
/// General via `onAppear`). The screenshot harness passes an explicit category so the captured
|
||||
/// shot opens on a real settings page (a populated detail) rather than the bare category list.
|
||||
init(initialCategory: SettingsCategory? = nil) {
|
||||
_settingsSelection = State(initialValue: initialCategory)
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
||||
@@ -66,6 +95,7 @@ struct SettingsView: View {
|
||||
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
windowSection
|
||||
statisticsSection
|
||||
}
|
||||
@@ -106,29 +136,116 @@ struct SettingsView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - iOS: one grouped Form
|
||||
// MARK: - iOS / iPadOS: adaptive split view
|
||||
|
||||
#if os(iOS)
|
||||
private var iosBody: some View {
|
||||
Form {
|
||||
streamModeSection
|
||||
audioSection
|
||||
compositorSection
|
||||
presenterSection
|
||||
statisticsSection
|
||||
experimentalSection
|
||||
controllersSection
|
||||
Section {
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(selection: $settingsSelection) {
|
||||
ForEach(SettingsCategory.allCases) { category in
|
||||
// On iPhone the split view collapses to a push list, but a selection List
|
||||
// draws no disclosure indicator of its own — add one in compact width for the
|
||||
// expected drill-in affordance. On iPad the selected row highlights instead, so
|
||||
// the chevron is omitted there.
|
||||
HStack {
|
||||
Label(category.title, systemImage: category.symbol)
|
||||
if horizontalSizeClass == .compact {
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
// Purely a drill-in affordance — the row's button trait already
|
||||
// conveys "opens"; keep it out of the VoiceOver announcement.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.tag(category)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
// NavigationSplitView hosts the detail in its own navigation context (its title bar),
|
||||
// so no inner NavigationStack — that would double the bar on iPad. On iPhone the split
|
||||
// view collapses to one stack and pushes this when a row is tapped. `?? .general` only
|
||||
// backs the brief pre-selection window; the list never auto-pushes on a nil selection.
|
||||
settingsDetail(settingsSelection ?? .general)
|
||||
// Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the
|
||||
// iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the
|
||||
// sidebar is showing, its Done is the only one — so this stays hidden to avoid two.
|
||||
.toolbar {
|
||||
if horizontalSizeClass == .compact || columnVisibility == .detailOnly {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
if horizontalSizeClass == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
// A regular→regular launch sets the default above; this catches a compact→regular change
|
||||
// (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in.
|
||||
.onChange(of: horizontalSizeClass) { _, newValue in
|
||||
if newValue == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsDetail(_ category: SettingsCategory) -> some View {
|
||||
switch category {
|
||||
case .general:
|
||||
Form {
|
||||
streamModeSection
|
||||
pointerSection
|
||||
compositorSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .display:
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
statisticsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Display")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .audio:
|
||||
Form { audioSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Audio")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .controllers:
|
||||
Form { controllersSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Controllers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .advanced:
|
||||
Form { experimentalSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Advanced")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .about:
|
||||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||
// the large title from the "Settings" sidebar root).
|
||||
AcknowledgementsView()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - tvOS
|
||||
@@ -156,6 +273,10 @@ struct SettingsView: View {
|
||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var hdrEnabledTag: Binding<String> {
|
||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var tvBody: some View {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
@@ -186,20 +307,25 @@ struct SettingsView: View {
|
||||
selection: $audioChannels)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: compositors, selection: $compositor)
|
||||
#if DEBUG
|
||||
TVSelectionRow(
|
||||
title: "Presenter",
|
||||
options: [("Stage 1 (default)", "stage1"), ("Stage 2 (experimental)", "stage2")],
|
||||
title: "Presenter (debug)",
|
||||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||||
selection: $presenter)
|
||||
#endif
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||
Text("The host creates a virtual output at exactly this mode — native "
|
||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||
+ "is honored only if available on the host.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
@@ -219,7 +345,7 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Controller type", options: Self.padTypes, selection: $gamepadType)
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
@@ -243,6 +369,63 @@ struct SettingsView: View {
|
||||
|
||||
@ViewBuilder private var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS)
|
||||
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
|
||||
// a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host
|
||||
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
|
||||
// last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||
Text("×")
|
||||
@@ -253,6 +436,7 @@ struct SettingsView: View {
|
||||
LabeledContent("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
@@ -267,7 +451,7 @@ struct SettingsView: View {
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
@@ -277,11 +461,85 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
|
||||
/// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't
|
||||
/// collide with a resolution.
|
||||
private static let customResolutionTag = "custom"
|
||||
|
||||
/// 16:9 then ultrawide presets; the device's native mode is prepended at runtime.
|
||||
private static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
||||
("720p", 1280, 720),
|
||||
("1080p", 1920, 1080),
|
||||
("1440p", 2560, 1440),
|
||||
("4K", 3840, 2160),
|
||||
("Ultrawide 1080p", 2560, 1080),
|
||||
("Ultrawide 1440p", 3440, 1440),
|
||||
("Super ultrawide", 5120, 1440),
|
||||
]
|
||||
|
||||
/// The non-custom wheel rows: this device's native mode first, then the presets, deduped by
|
||||
/// dimensions (native wins a tie).
|
||||
private var resolutionModes: [(name: String, w: Int, h: Int)] {
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||||
let native = (w: Int(max(bounds.width, bounds.height)), h: Int(min(bounds.width, bounds.height)))
|
||||
let all = [(name: "This device", w: native.w, h: native.h)] + Self.resolutionPresets
|
||||
var seen = Set<String>()
|
||||
return all.filter { seen.insert("\($0.w)x\($0.h)").inserted }
|
||||
}
|
||||
|
||||
/// Wheel rows: the resolution modes, then a "Custom…" row that reveals the numeric fields.
|
||||
private var resolutionChoices: [(label: String, tag: String)] {
|
||||
resolutionModes.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
|
||||
+ [(label: "Custom…", tag: Self.customResolutionTag)]
|
||||
}
|
||||
|
||||
private var presetResolutionTags: Set<String> {
|
||||
Set(resolutionModes.map { "\($0.w)x\($0.h)" })
|
||||
}
|
||||
|
||||
/// True when the editable custom fields should show: the wheel is parked on "Custom…" (sticky),
|
||||
/// or the stored size simply isn't one of the presets (e.g. a value synced from a Mac) — so a
|
||||
/// non-preset mode stays editable across relaunches without a persisted flag.
|
||||
private var isCustomResolution: Bool {
|
||||
customMode || !presetResolutionTags.contains("\(width)x\(height)")
|
||||
}
|
||||
|
||||
/// The wheel works in "WxH" tags so one selection drives both width and height; the custom
|
||||
/// sentinel toggles `customMode` instead of writing a size.
|
||||
private var resolutionSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { isCustomResolution ? Self.customResolutionTag : "\(width)x\(height)" },
|
||||
set: { tag in
|
||||
if tag == Self.customResolutionTag {
|
||||
customMode = true
|
||||
return
|
||||
}
|
||||
customMode = false
|
||||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||
guard parts.count == 2 else { return }
|
||||
width = parts[0]
|
||||
height = parts[1]
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh rates the device can actually display (no point asking the host to render frames the
|
||||
/// screen can't show), plus any stored custom value so it stays selectable.
|
||||
private var refreshChoices: [Int] {
|
||||
let maxHz = UIScreen.main.maximumFramesPerSecond
|
||||
var rates = [60, 120, 240].filter { $0 <= maxHz }
|
||||
if rates.isEmpty { rates = [maxHz] }
|
||||
if !rates.contains(hz) { rates.append(hz) }
|
||||
return rates.sorted()
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder private var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
@@ -321,11 +579,35 @@ struct SettingsView: View {
|
||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
||||
+ "host's virtual mic. System default follows macOS device changes. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs
|
||||
/// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock —
|
||||
/// the mouse path there is always the absolute fallback).
|
||||
@ViewBuilder private var pointerSection: some View {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Section {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
} header: {
|
||||
Text("Pointer")
|
||||
} footer: {
|
||||
Text("With a mouse or trackpad connected, lock the pointer and send relative "
|
||||
+ "movement — the expected behavior for games (mouse-look). Turn this off for "
|
||||
+ "desktop use to keep the pointer free and send its absolute position instead. "
|
||||
+ "The lock needs the stream full-screen and frontmost; it falls back to the "
|
||||
+ "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is "
|
||||
+ "unaffected. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder private var compositorSection: some View {
|
||||
Section {
|
||||
Picker("Compositor", selection: $compositor) {
|
||||
@@ -341,7 +623,7 @@ struct SettingsView: View {
|
||||
Text("Which compositor drives the virtual output on the host. A specific "
|
||||
+ "choice is honored only if that backend is available there — "
|
||||
+ "otherwise the host falls back to auto-detection.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -355,26 +637,50 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||
+ "list, so only the stream is fullscreen — not the picker.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
||||
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
||||
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
||||
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
|
||||
@ViewBuilder private var presenterSection: some View {
|
||||
#if DEBUG
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 1 (default)").tag("stage1")
|
||||
Text("Stage 2 (experimental)").tag("stage2")
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter")
|
||||
Text("Video presenter · debug")
|
||||
} footer: {
|
||||
Text("Stage 1 feeds compressed video to the system display layer (known-good). "
|
||||
+ "Stage 2 decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD "
|
||||
+ "and shortens the present tail. Applies from the next session.")
|
||||
.font(.caption)
|
||||
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD and "
|
||||
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
|
||||
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
|
||||
+ "fallback only. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var hdrSection: some View {
|
||||
Section {
|
||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||
} header: {
|
||||
Text("Video quality")
|
||||
} footer: {
|
||||
Text("HDR requests a 10-bit BT.2020 PQ (HDR10) stream — it only engages when the host is "
|
||||
+ "sending HDR content AND this display supports HDR. 4:4:4 requests full chroma "
|
||||
+ "(sharper text/UI, more bandwidth) — it only engages when this device can "
|
||||
+ "hardware-decode it AND the host opted in. Otherwise the stream stays 8-bit "
|
||||
+ "4:2:0 SDR. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -392,7 +698,7 @@ struct SettingsView: View {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text(Self.statisticsFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -405,9 +711,9 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||
+ "The host must expose that API on the LAN with a token "
|
||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||
.font(.caption)
|
||||
+ "Works once you've paired with the host — the library is authorized by this "
|
||||
+ "device's certificate, with no extra host setup.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -441,7 +747,7 @@ struct SettingsView: View {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -593,13 +899,13 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if gamepads.active?.id == controller.id {
|
||||
Text("In use")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(.green.opacity(0.2)))
|
||||
@@ -621,6 +927,10 @@ struct SettingsView: View {
|
||||
width = Int(max(bounds.width, bounds.height))
|
||||
height = Int(min(bounds.width, bounds.height))
|
||||
hz = UIScreen.main.maximumFramesPerSecond
|
||||
#if os(iOS)
|
||||
// The native mode is the "This device" wheel row, so leave Custom mode if it was on.
|
||||
customMode = false
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -631,3 +941,52 @@ extension Double {
|
||||
Swift.min(Swift.max(self, lo), hi)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The settings groups, mirroring the macOS preference tabs. On iPad each is a sidebar row that
|
||||
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
||||
/// private) so the screenshot harness can open SettingsView on a specific category.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general, display, audio, controllers, advanced, about
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: return "General"
|
||||
case .display: return "Display"
|
||||
case .audio: return "Audio"
|
||||
case .controllers: return "Controllers"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .general: return "gearshape"
|
||||
case .display: return "display"
|
||||
case .audio: return "speaker.wave.2"
|
||||
case .controllers: return "gamecontroller"
|
||||
case .advanced: return "slider.horizontal.3"
|
||||
case .about: return "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Present the settings sheet large on iPad so the NavigationSplitView has room for its
|
||||
/// sidebar + detail — a default form sheet is too narrow and the split view would collapse to
|
||||
/// the iPhone push list. No-op on iPhone (the standard sheet is already right) and on iOS 17
|
||||
/// (no `presentationSizing` — it falls back to the default sheet, which still degrades cleanly
|
||||
/// to the push list).
|
||||
@ViewBuilder
|
||||
func settingsSheetSizing() -> some View {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad, #available(iOS 18, *) {
|
||||
presentationSizing(.page)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -52,7 +52,7 @@ struct SpeedTestSheet: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
|
||||
.font(.headline)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
switch phase {
|
||||
@@ -73,7 +73,7 @@ struct SpeedTestSheet: View {
|
||||
resultView(result)
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
@@ -149,13 +149,13 @@ struct SpeedTestSheet: View {
|
||||
if let rec = Self.recommendedKbps(result) {
|
||||
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
|
||||
+ "(~70% of measured, headroom for encoder bursts).")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Text("Too little data made it through to recommend a bitrate — "
|
||||
+ "check the network and retry.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
@@ -69,19 +69,19 @@ struct StreamHUDView: View {
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases the mouse"
|
||||
: "Click the stream to capture input")
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
// The client-side cursor (⌘⇧C) draws the local cursor over the stream instead of
|
||||
// capturing it — the only accurate cursor for gamescope, whose capture has none.
|
||||
Text("⌘⇧C toggles the on-screen cursor")
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
#elseif os(iOS)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases keyboard & mouse"
|
||||
: "⌘⎋ captures keyboard & mouse")
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
@@ -89,13 +89,13 @@ struct StreamHUDView: View {
|
||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
#else
|
||||
// ⌘D lives on the app's Stream menu (so it still works when the HUD is hidden);
|
||||
// this button is the in-overlay, click-to-disconnect affordance.
|
||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// or drops this and runs the PIN pairing ceremony instead.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct TrustCardView: View {
|
||||
@@ -18,11 +19,11 @@ struct TrustCardView: View {
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
Text("Verify \(hostName)")
|
||||
.font(.title3.weight(.semibold))
|
||||
.font(.geist(20, .semibold, relativeTo: .title3))
|
||||
Text("First connection. Compare this fingerprint with the one "
|
||||
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
||||
+ "fingerprint\u{201D}):")
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(Self.format(fingerprint: fingerprint))
|
||||
@@ -58,7 +59,7 @@ struct TrustCardView: View {
|
||||
#else
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 440)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Geist — the punktfunk brand typeface (the same family the website ships). Bundled as static
|
||||
// OTF weights in this kit's resources and registered with Core Text at first use, so it works
|
||||
// identically in the Xcode app and the `swift run` dev shell (Bundle.module resolves to the
|
||||
// package resource bundle in both). Geist Sans carries titles/UI; Geist Mono carries the technical
|
||||
// readouts — host addresses, status labels, the stream-stats HUD — for the industrial look.
|
||||
//
|
||||
// Licensed under the SIL Open Font License 1.1 (Resources/Fonts/Geist-OFL.txt).
|
||||
|
||||
import CoreText
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public enum BrandFont {
|
||||
public enum Weight {
|
||||
case regular, medium, semibold, bold
|
||||
}
|
||||
|
||||
/// PostScript names of the bundled faces (verified from each OTF's name table). Geist Sans only
|
||||
/// — Geist Mono is intentionally not shipped; the app's typeface is Geist Sans throughout.
|
||||
private static let sansFaces = ["Geist-Regular", "Geist-Medium", "Geist-SemiBold", "Geist-Bold"]
|
||||
|
||||
/// Registered exactly once per process — a static `let` initializer is run lazily and is
|
||||
/// guaranteed thread-safe + run-at-most-once by the runtime.
|
||||
private static let registered: Void = {
|
||||
for face in sansFaces {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: face, withExtension: "otf", subdirectory: "Fonts") else {
|
||||
#if DEBUG
|
||||
print("BrandFont: bundled face \(face).otf not found — text will fall back to system")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
var error: Unmanaged<CFError>?
|
||||
if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) {
|
||||
#if DEBUG
|
||||
let message = error?.takeRetainedValue().localizedDescription ?? "unknown error"
|
||||
print("BrandFont: failed to register \(face): \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
/// Force registration before the first `Font.custom` lookup. Cheap to call repeatedly.
|
||||
public static func registerIfNeeded() { _ = registered }
|
||||
|
||||
fileprivate static func sansFace(_ weight: Weight) -> String {
|
||||
switch weight {
|
||||
case .regular: return "Geist-Regular"
|
||||
case .medium: return "Geist-Medium"
|
||||
case .semibold: return "Geist-SemiBold"
|
||||
case .bold: return "Geist-Bold"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Color {
|
||||
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
||||
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
||||
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
||||
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
||||
static let brand: Color = {
|
||||
#if canImport(UIKit)
|
||||
return Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||
})
|
||||
#elseif canImport(AppKit)
|
||||
return Color(NSColor(name: nil) { appearance in
|
||||
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||
})
|
||||
#else
|
||||
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
||||
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
|
||||
public extension Font {
|
||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||
static func geist(
|
||||
_ size: CGFloat, _ weight: BrandFont.Weight = .regular,
|
||||
relativeTo textStyle: TextStyle = .body
|
||||
) -> Font {
|
||||
BrandFont.registerIfNeeded()
|
||||
return .custom(BrandFont.sansFace(weight), size: size, relativeTo: textStyle)
|
||||
}
|
||||
|
||||
/// Geist Sans at a FIXED point size that does not scale with Dynamic Type — for glyphs pinned
|
||||
/// inside a fixed-size container (e.g. the monogram tile), where a scaled letter would overflow.
|
||||
static func geistFixed(_ size: CGFloat, _ weight: BrandFont.Weight = .regular) -> Font {
|
||||
BrandFont.registerIfNeeded()
|
||||
return .custom(BrandFont.sansFace(weight), fixedSize: size)
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,22 @@ public enum DefaultsKey {
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
public static let micUID = "punktfunk.micUID"
|
||||
public static let presenter = "punktfunk.presenter"
|
||||
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
|
||||
/// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR.
|
||||
public static let hdrEnabled = "punktfunk.hdrEnabled"
|
||||
/// Request a full-chroma 4:4:4 stream when this device can HARDWARE-decode it (`Stage444Probe`).
|
||||
/// On by default; only takes effect when the host also opted in to 4:4:4 (otherwise the stream
|
||||
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
|
||||
public static let enable444 = "punktfunk.enable444"
|
||||
public static let hosts = "punktfunk.hosts"
|
||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||
public static let cursorMode = "punktfunk.cursorMode"
|
||||
/// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games,
|
||||
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
|
||||
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
||||
/// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide
|
||||
/// Over). Read by `StreamViewController.prefersPointerLocked`.
|
||||
public static let pointerCapture = "punktfunk.pointerCapture"
|
||||
/// Experimental: show the host's game library (browsed over the management API). Off by default.
|
||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||
|
||||
@@ -68,6 +68,14 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
private var broken = false
|
||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||
private var wasActive = false
|
||||
// Backoff after an engine failure. A broken `gamecontrollerd.haptics` XPC connection (CoreHaptics
|
||||
// -4811 "server connection broke") fails EVERY rebuild until the service relaunches — and that
|
||||
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
|
||||
// update immediately rebuilds into the same dead connection, flooding the log and never
|
||||
// recovering. Delay the next setup() — growing 0.5→1→2→4 s on repeated failure — and clear it
|
||||
// the moment a player runs cleanly (or the controller changes).
|
||||
private var retryAfter = Date.distantPast
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
/// 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
|
||||
@@ -91,6 +99,8 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
self.closeHID()
|
||||
self.controller = c
|
||||
self.broken = false
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
_ = self.openHIDIfDualSense(c)
|
||||
onBackend?(self.backendNote(for: c))
|
||||
}
|
||||
@@ -108,7 +118,7 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
// 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 }
|
||||
if active, self.low == nil, self.high == nil {
|
||||
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
|
||||
self.setup()
|
||||
}
|
||||
let ok: Bool
|
||||
@@ -124,8 +134,15 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
// 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() }
|
||||
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
|
||||
// update; once a player is actually running the path has recovered, so clear the backoff.
|
||||
if !ok {
|
||||
self.teardown()
|
||||
self.scheduleRetryBackoff()
|
||||
} else if self.low?.player != nil || self.high?.player != nil {
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,14 +174,29 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
low = makeMotor(haptics, .default)
|
||||
}
|
||||
if low == nil, high == nil {
|
||||
// Haptics present but no engine could be built right now (server busy / a transient
|
||||
// error). Do NOT latch broken — the next nonzero amplitude retries setup().
|
||||
log.warning("rumble: haptics present but engine setup failed — will retry on next rumble")
|
||||
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
|
||||
// NOT latch broken — back off and the next nonzero amplitude past the cooldown retries.
|
||||
log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
|
||||
scheduleRetryBackoff()
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the next engine-build attempt out after a failure (capped exponential backoff), so a
|
||||
/// broken `gamecontrollerd.haptics` connection gets time to relaunch instead of being re-hit on
|
||||
/// every rumble update.
|
||||
private func scheduleRetryBackoff() {
|
||||
consecutiveFailures += 1
|
||||
let shift = min(consecutiveFailures - 1, 4)
|
||||
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4))
|
||||
}
|
||||
|
||||
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
||||
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
|
||||
// letting a haptics-only engine join it is a needless coupling that can get its
|
||||
// gamecontrollerd XPC connection interrupted (the repeated -4811 server-connection breaks).
|
||||
engine.playsHapticsOnly = true
|
||||
// The haptic server can stop or reset the engine out from under us — app backgrounding, an
|
||||
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
|
||||
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
||||
@@ -338,29 +370,32 @@ public final class GamepadFeedback {
|
||||
// Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
|
||||
// session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
|
||||
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
|
||||
let hasHidout = connection.resolvedGamepad == .dualSense
|
||||
|| connection.resolvedGamepad == .dualShock4
|
||||
let hidTimeout: UInt32 = hasHidout ? 10 : 0
|
||||
let thread = Thread { [connection, flag, drainDone, weak self] in
|
||||
while !flag.isStopped {
|
||||
do {
|
||||
if let r = try connection.nextRumble(timeoutMs: 10), r.pad == 0 {
|
||||
// Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds
|
||||
// the connection's shared feedback lock for its whole wait; the video pump drains
|
||||
// HDR mastering metadata (nextHdrMeta) on the SAME lock every frame, so a blocking
|
||||
// poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR
|
||||
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
||||
// rumble/HID latency low while leaving the lock free between polls.
|
||||
if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 {
|
||||
self?.rumble.apply(low: r.low, high: r.high)
|
||||
}
|
||||
// Drain a BOUNDED burst of hidout events: only the first poll waits,
|
||||
// and the cap + stop check keep sustained 0xCD traffic (a game writing
|
||||
// per-frame LED/trigger reports) from starving the rumble poll above
|
||||
// or blocking stop() past one cycle.
|
||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||
var burst = 0
|
||||
while burst < 64, !flag.isStopped,
|
||||
let ev = try connection.nextHidOutput(
|
||||
timeoutMs: burst == 0 ? hidTimeout : 0) {
|
||||
let ev = try connection.nextHidOutput(timeoutMs: 0) {
|
||||
self?.render(ev)
|
||||
burst += 1
|
||||
}
|
||||
} catch {
|
||||
break // .closed (or fatal) — the session is over
|
||||
}
|
||||
// ~8 ms poll cadence (≈125 Hz), slept OUTSIDE the feedback lock — low rumble/HID
|
||||
// latency without holding the lock the HDR-meta drain needs.
|
||||
if !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) }
|
||||
}
|
||||
drainDone.signal()
|
||||
}
|
||||
|
||||
@@ -160,7 +160,13 @@ public final class InputCapture {
|
||||
previous.onPreempted?()
|
||||
}
|
||||
Self.activeCapture = self
|
||||
if let mouse = GCMouse.current { attach(mouse: mouse) }
|
||||
// Attach EVERY connected mouse, not just GCMouse.current. With two pointing devices (e.g.
|
||||
// the iPad's own Magic Keyboard trackpad AND a Universal Control "V-UC Automouse"), only one
|
||||
// is `current` at a time; attaching just that one left the OTHER device's motion handler
|
||||
// uninstalled, so moving it did nothing. Each GCMouse delivers its own deltas through its own
|
||||
// handler, so handling all of them lets either device drive. New arrivals are caught by the
|
||||
// GCMouseDidConnect observer below.
|
||||
for mouse in GCMouse.mice() { attach(mouse: mouse) }
|
||||
if let keyboard = GCKeyboard.coalesced { attach(keyboard: keyboard) }
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .GCMouseDidConnect, object: nil, queue: .main
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
// /library page renders. Read-only on the client for now; launching a chosen title is a later
|
||||
// step. Gated behind `DefaultsKey.libraryEnabled` in the UI.
|
||||
//
|
||||
// The management API is HTTP on a port distinct from the punktfunk/1 data plane (default 47990),
|
||||
// binds loopback unless started with a token, and REQUIRES a bearer token for any non-loopback
|
||||
// bind. So to browse a host's library remotely the host must expose the mgmt API on the LAN with
|
||||
// `--mgmt-token`; the client carries that token per host. This mirrors the GameEntry/Artwork/
|
||||
// The management API serves HTTPS on a port distinct from the punktfunk/1 data plane (default
|
||||
// 47990, also advertised in the host's mDNS `mgmt` TXT). A paired client is authorized for the
|
||||
// read-only library route by its **mTLS certificate** — no bearer token. The host binds this read
|
||||
// surface to the LAN by DEFAULT (the bearer-gated admin surface stays loopback-only), so a paired
|
||||
// client browses a host's library with no operator step. This mirrors the GameEntry/Artwork/
|
||||
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
|
||||
|
||||
import Foundation
|
||||
@@ -56,8 +57,9 @@ public enum LibraryError: LocalizedError {
|
||||
case .http(let code):
|
||||
return "The management API returned HTTP \(code)."
|
||||
case .unreachable(let why):
|
||||
return "Couldn't reach the host's management API: \(why). The host must expose it on "
|
||||
+ "the LAN (serve --mgmt-bind 0.0.0.0)."
|
||||
return "Couldn't reach the host's management API: \(why). It binds the LAN by default, "
|
||||
+ "so check the host is updated and reachable (a host pinned to "
|
||||
+ "`--mgmt-bind 127.0.0.1` is loopback-only and can't be browsed remotely)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,35 @@ public enum Licenses {
|
||||
+ apache
|
||||
}
|
||||
|
||||
/// The bundled brand typeface (Geist Sans + Geist Mono) — SIL Open Font License 1.1. The
|
||||
/// license file ships alongside the OTFs in `Resources/Fonts/`, satisfying the OFL's
|
||||
/// distribution requirement; this surfaces it in the Acknowledgements screen too.
|
||||
public static var fontLicense: String {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: "Geist-OFL", withExtension: "txt", subdirectory: "Fonts"),
|
||||
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||
else { return "" }
|
||||
return text
|
||||
}
|
||||
|
||||
/// Third-party software notices for the linked Rust crates (generated by
|
||||
/// `scripts/gen-third-party-notices.sh`).
|
||||
public static var thirdPartyNotices: String {
|
||||
let text = resource("THIRD-PARTY-NOTICES")
|
||||
return text.isEmpty ? "Third-party notices unavailable." : text
|
||||
}
|
||||
|
||||
/// `thirdPartyNotices` pre-split into render-sized line chunks. The full notices are ~885 KB /
|
||||
/// 16k lines; a single SwiftUI `Text` that large overshoots CoreText/CoreAnimation's max
|
||||
/// renderable height — it lays out for ages and draws blank past the limit — so the
|
||||
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
|
||||
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
|
||||
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
|
||||
public static let thirdPartyNoticesChunks: [String] = {
|
||||
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
let chunkSize = 200
|
||||
return stride(from: 0, to: lines.count, by: chunkSize).map { start in
|
||||
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
// Stage-2 presenter, present half: draw a decoded NV12 CVPixelBuffer into a CAMetalLayer
|
||||
// drawable with a BT.709 YUV→RGB shader. The display link (owned by the hosting view) drives
|
||||
// `render` once per vsync with the target present time, so a present can finally be stamped and
|
||||
// the present tail hand-paced. See docs apple-stage2-presenter.md.
|
||||
// Stage-2 presenter, present half: draw a decoded NV12 / P010 / 4:4:4 CVPixelBuffer into a CAMetalLayer
|
||||
// drawable with a Y′CbCr→RGB shader. The hosting view's CADisplayLink drives `render` once per vsync
|
||||
// (via Stage2Pipeline.renderTick) with the target present time, so a present can be stamped and the
|
||||
// present tail hand-paced. See docs apple-stage2-presenter.md.
|
||||
//
|
||||
// Main-thread only: created during view setup, `render` called from the view's CADisplayLink
|
||||
// (which fires on the main runloop). The Metal objects + texture cache are touched only here.
|
||||
// Main-thread only: created during view setup, `render`/`configure` called from the view's CADisplayLink
|
||||
// (which fires on the main runloop). The Metal objects + texture cache are touched only here. The one
|
||||
// exception is `setHdrMeta`, called from the pump thread — it hops the layer write to main so every
|
||||
// CALayer mutation stays on one thread.
|
||||
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import CoreGraphics
|
||||
import CoreVideo
|
||||
import Metal
|
||||
import QuartzCore
|
||||
import os
|
||||
|
||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a
|
||||
/// BT.709 limited-range NV12→RGB fragment shader. uv.y is flipped (1 - p.y) so the top-left-
|
||||
/// origin texture presents upright (NDC y is up), not upside down. (Colorspace is BT.709 SDR
|
||||
/// for now — matches the host; 10-bit/HDR + other matrices are a later tie-in.)
|
||||
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||
|
||||
/// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the
|
||||
/// PQ signal's diffuse white sits at. Passed to `CAEDRMetadata.hdr10(opticalOutputScale:)`, it anchors
|
||||
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
|
||||
/// brighter highlights into the panel's headroom. This is the missing anchor that made the old HDR path
|
||||
/// render "way too bright" (no `edrMetadata` → no reference-white anchoring); a LARGER value renders
|
||||
/// dimmer. Matches the host's standard PQ reference white.
|
||||
private let hdrReferenceWhiteNits: Float = 203.0
|
||||
|
||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and BT.709 SDR
|
||||
/// and BT.2020-PQ HDR Y′CbCr→RGB fragment shaders. uv.y is flipped (1 - p.y) so the top-left-origin
|
||||
/// texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded R′G′B′ as-is — the
|
||||
/// CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system compositor the samples
|
||||
/// are PQ and how to tone-map them (no EOTF here, matching the host's BT.2020 PQ emission).
|
||||
private let shaderSource = """
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
@@ -30,11 +44,46 @@ vertex VOut pf_vtx(uint vid [[vertex_id]]) {
|
||||
return o;
|
||||
}
|
||||
|
||||
// Bicubic (Catmull-Rom) sampling of the single-channel luma plane. When the drawable is larger
|
||||
// than the decoded frame (a window/view bigger than the host's fixed mode), a bilinear upscale
|
||||
// looks soft; Catmull-Rom keeps edges crisp — matching AVSampleBufferDisplayLayer's (stage-1)
|
||||
// scaler — and reduces to the exact texel at 1:1, so a native-resolution present stays pixel-exact.
|
||||
// Nine bilinear taps (TheRealMJP's optimisation of the 16-tap kernel); `s` MUST be a linear
|
||||
// sampler. Luma carries the perceived detail, so only it gets bicubic; chroma stays bilinear.
|
||||
float catmullRomLuma(texture2d<float> tex, sampler s, float2 uv) {
|
||||
float2 texSize = float2(tex.get_width(), tex.get_height());
|
||||
float2 samplePos = uv * texSize;
|
||||
float2 tc1 = floor(samplePos - 0.5) + 0.5;
|
||||
float2 f = samplePos - tc1;
|
||||
float2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
float2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
float2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
float2 w3 = f * f * (-0.5 + 0.5 * f);
|
||||
float2 w12 = w1 + w2;
|
||||
float2 off12 = w2 / w12;
|
||||
float2 tc0 = (tc1 - 1.0) / texSize;
|
||||
float2 tc3 = (tc1 + 2.0) / texSize;
|
||||
float2 tc12 = (tc1 + off12) / texSize;
|
||||
float r = 0.0;
|
||||
r += tex.sample(s, float2(tc0.x, tc0.y)).r * (w0.x * w0.y);
|
||||
r += tex.sample(s, float2(tc12.x, tc0.y)).r * (w12.x * w0.y);
|
||||
r += tex.sample(s, float2(tc3.x, tc0.y)).r * (w3.x * w0.y);
|
||||
r += tex.sample(s, float2(tc0.x, tc12.y)).r * (w0.x * w12.y);
|
||||
r += tex.sample(s, float2(tc12.x, tc12.y)).r * (w12.x * w12.y);
|
||||
r += tex.sample(s, float2(tc3.x, tc12.y)).r * (w3.x * w12.y);
|
||||
r += tex.sample(s, float2(tc0.x, tc3.y)).r * (w0.x * w3.y);
|
||||
r += tex.sample(s, float2(tc12.x, tc3.y)).r * (w12.x * w3.y);
|
||||
r += tex.sample(s, float2(tc3.x, tc3.y)).r * (w3.x * w3.y);
|
||||
return r;
|
||||
}
|
||||
|
||||
// SDR: 8-bit NV12 / 4:4:4 (BT.709, limited/video range) → full-range RGB. Chroma is sampled at the
|
||||
// same UV as luma, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0.
|
||||
fragment float4 pf_frag(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
float y = lumaTex.sample(s, in.uv).r;
|
||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||
float2 c = chromaTex.sample(s, in.uv).rg;
|
||||
// BT.709, 8-bit limited (video) range → full-range RGB.
|
||||
y = (y - 16.0/255.0) * (255.0/219.0);
|
||||
@@ -46,18 +95,18 @@ fragment float4 pf_frag(VOut in [[stage_in]],
|
||||
return float4(saturate(float3(r, g, b)), 1.0);
|
||||
}
|
||||
|
||||
// HDR: 10-bit P010 (BT.2020, limited range), Y'CbCr that is PQ-encoded. We apply the BT.2020
|
||||
// matrix to get PQ-encoded R'G'B' and output it as-is — the CAMetalLayer's itur_2100_PQ colour
|
||||
// space + EDR tells the compositor the samples are PQ, so it does the PQ→display mapping. No EOTF
|
||||
// here (matching the host, which emitted BT.2020 PQ). P010 stores the 10-bit code in the high bits
|
||||
// of each 16-bit sample, so an .r16Unorm sample reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
|
||||
// HDR: 10-bit P010 / 4:4:4 (BT.2020, limited range), Y′CbCr that is PQ-encoded. We apply the BT.2020
|
||||
// matrix to get PQ-encoded R′G′B′ and output it as-is — the CAMetalLayer's itur_2100_PQ colour space
|
||||
// + edrMetadata tell the compositor the samples are PQ, so it does the PQ→display tone-map. No EOTF
|
||||
// here. P010/x444 store the 10-bit code in the high bits of each 16-bit sample, so an .r16Unorm sample
|
||||
// reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
|
||||
fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
float y = lumaTex.sample(s, in.uv).r;
|
||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||
float2 c = chromaTex.sample(s, in.uv).rg;
|
||||
// BT.2020 10-bit limited (video) range → full-range PQ R'G'B'.
|
||||
// BT.2020 10-bit limited (video) range → full-range PQ R′G′B′.
|
||||
y = (y - 64.0/1023.0) * (1023.0/876.0);
|
||||
float u = (c.x - 512.0/1023.0) * (1023.0/896.0);
|
||||
float v = (c.y - 512.0/1023.0) * (1023.0/896.0);
|
||||
@@ -74,21 +123,34 @@ public final class MetalVideoPresenter {
|
||||
|
||||
private let device: MTLDevice
|
||||
private let queue: MTLCommandQueue
|
||||
/// SDR (BT.709 8-bit NV12 → bgra8) and HDR (BT.2020 PQ 10-bit P010 → rgba16Float) pipelines.
|
||||
/// Selected per frame by `render`; the layer is reconfigured when the mode flips (HDR toggle).
|
||||
/// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per
|
||||
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
|
||||
private let pipelineSDR: MTLRenderPipelineState
|
||||
private let pipelineHDR: MTLRenderPipelineState
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
/// Current layer configuration — switched lazily in `configure(hdr:)` when a frame's mode differs.
|
||||
private var hdrActive = false
|
||||
|
||||
/// nil if Metal is unavailable (no GPU / a headless CI) — the caller falls back to stage-1.
|
||||
public init?() {
|
||||
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
||||
/// Main-thread only (read + written from `render`/`configure`, all on the display-link runloop).
|
||||
private var hdrActive = false
|
||||
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
|
||||
/// SDR→HDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
|
||||
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
|
||||
/// `edrMetadata`). Main-thread only.
|
||||
private var lastHdrMeta: PunktfunkConnection.HdrMeta?
|
||||
|
||||
#if DEBUG
|
||||
/// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change.
|
||||
private var lastSizeSig = ""
|
||||
#endif
|
||||
|
||||
/// nil if Metal is unavailable (no GPU / a headless CI) or a shader fails to compile — the caller
|
||||
/// falls back to stage-1.
|
||||
public static func make() -> MetalVideoPresenter? {
|
||||
guard let device = MTLCreateSystemDefaultDevice(),
|
||||
let queue = device.makeCommandQueue()
|
||||
else { return nil }
|
||||
self.device = device
|
||||
self.queue = queue
|
||||
let pipelineSDR: MTLRenderPipelineState
|
||||
let pipelineHDR: MTLRenderPipelineState
|
||||
do {
|
||||
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
||||
let vtx = library.makeFunction(name: "pf_vtx")
|
||||
@@ -105,76 +167,148 @@ public final class MetalVideoPresenter {
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &textureCache)
|
||||
guard textureCache != nil else { return nil }
|
||||
var cache: CVMetalTextureCache?
|
||||
CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &cache)
|
||||
guard let textureCache = cache else { return nil }
|
||||
|
||||
let layer = CAMetalLayer()
|
||||
layer.device = device
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.framebufferOnly = true
|
||||
layer.isOpaque = true
|
||||
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the
|
||||
// display-link / MAIN thread) has to block waiting for one to free.
|
||||
layer.maximumDrawableCount = 3
|
||||
#if os(macOS)
|
||||
// The display link already paces exactly one present per vsync. Leaving the layer's
|
||||
// own vsync wait on means `commandBuffer.present` ALSO blocks for the hardware vsync,
|
||||
// so `nextDrawable()` stalls the MAIN thread until a drawable frees — windowed, the
|
||||
// WindowServer's looser compositing hides it; FULLSCREEN's tighter, more-direct path
|
||||
// serializes the main thread to the display and the stall surfaces as bad judder.
|
||||
// Disabling the layer-level sync lets present return promptly (the display link is the
|
||||
// pacing source), which is what fixes the fullscreen stutter. macOS-only property.
|
||||
// The display link already paces exactly one present per vsync. Leaving the layer's own vsync
|
||||
// wait on means `commandBuffer.present` ALSO blocks for the hardware vsync, so `nextDrawable()`
|
||||
// stalls the MAIN thread until a drawable frees — windowed, the WindowServer's looser
|
||||
// compositing hides it; FULLSCREEN's tighter path serializes the main thread to the display and
|
||||
// the stall surfaces as bad judder. Disabling the layer-level sync lets present return promptly
|
||||
// (the display link is the pacing source) — the fix for the fullscreen stutter. macOS-only.
|
||||
layer.displaySyncEnabled = false
|
||||
#endif
|
||||
// Render the drawable at the DECODED frame's resolution (set per-frame in `render`) and let the
|
||||
// system compositor scale it to the layer's bounds — the same `.resizeAspect` path stage-1's
|
||||
// AVSampleBufferDisplayLayer uses. A native-resolution present is then pixel-exact (1:1, no
|
||||
// shader scaling); a resized window rescales via the system's scaler.
|
||||
layer.contentsGravity = .resizeAspect
|
||||
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the display-link /
|
||||
// MAIN thread) has to block waiting for one to free.
|
||||
layer.maximumDrawableCount = 3
|
||||
|
||||
return MetalVideoPresenter(
|
||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||
textureCache: textureCache, layer: layer)
|
||||
}
|
||||
|
||||
private init(
|
||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||
pipelineHDR: MTLRenderPipelineState, textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||
) {
|
||||
self.device = device
|
||||
self.queue = queue
|
||||
self.pipelineSDR = pipelineSDR
|
||||
self.pipelineHDR = pipelineHDR
|
||||
self.textureCache = textureCache
|
||||
self.layer = layer
|
||||
}
|
||||
|
||||
/// Track the stream mode (the host can Reconfigure mid-stream). Size is in pixels.
|
||||
public func setDrawableSize(_ size: CGSize) {
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
if layer.drawableSize != size { layer.drawableSize = size }
|
||||
}
|
||||
|
||||
/// Reconfigure the layer for SDR or HDR when the stream mode flips (HDR toggle). HDR uses an
|
||||
/// rgba16Float drawable + a BT.2020 PQ colour space + EDR, so the compositor PQ-maps to the
|
||||
/// display; SDR uses the plain 8-bit sRGB path. Main-thread only (called from `render`).
|
||||
private func configure(hdr: Bool) {
|
||||
/// Configure the layer + active pipeline for an SDR or HDR session. MAIN THREAD ONLY. Called once at
|
||||
/// session start and again per-frame from `render` (idempotent — the guard makes a same-state call a
|
||||
/// no-op), so a mid-session HDR toggle (the host re-inits its encoder; the decoded `frame.isHDR`
|
||||
/// flips) reconfigures here automatically. HDR uses an rgba16Float drawable + BT.2020 PQ colour space
|
||||
/// + EDR with a 203-nit reference-white anchor; SDR uses the plain 8-bit sRGB path.
|
||||
public func configure(hdr: Bool) {
|
||||
guard hdr != hdrActive else { return }
|
||||
hdrActive = hdr
|
||||
configureColor(hdr: hdr)
|
||||
}
|
||||
|
||||
/// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested
|
||||
/// on macOS + iOS (the old `#if os(macOS)` guard left iOS EDR half-engaged). tvOS has NO EDR API
|
||||
/// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there), so
|
||||
/// it gets the PQ pixel format + colour space only — the tvOS compositor tone-maps from those.
|
||||
private func configureColor(hdr: Bool) {
|
||||
if hdr {
|
||||
layer.pixelFormat = .rgba16Float
|
||||
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||
#if os(macOS)
|
||||
#if !os(tvOS)
|
||||
layer.wantsExtendedDynamicRangeContent = true
|
||||
// Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the
|
||||
// flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright.
|
||||
layer.edrMetadata = makeEDR(lastHdrMeta)
|
||||
#endif
|
||||
} else {
|
||||
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB
|
||||
// (the proven SDR path — never showed the "too bright" issue, which was HDR-only).
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.colorspace = nil
|
||||
#if os(macOS)
|
||||
#if !os(tvOS)
|
||||
layer.wantsExtendedDynamicRangeContent = false
|
||||
layer.edrMetadata = nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw one decoded frame to the next drawable and present it. `isHDR` selects the 10-bit
|
||||
/// BT.2020 PQ path (P010 input) vs the 8-bit BT.709 path (NV12 input). Returns true on success;
|
||||
/// false when there's no drawable yet, a texture couldn't be made, or Metal errored — the
|
||||
/// caller then doesn't stamp a present for this frame.
|
||||
#if !os(tvOS)
|
||||
private func makeEDR(_ meta: PunktfunkConnection.HdrMeta?) -> CAEDRMetadata {
|
||||
CAEDRMetadata.hdr10(
|
||||
displayInfo: meta?.masteringDisplayColorVolume(),
|
||||
contentInfo: meta?.contentLightLevelInfo(),
|
||||
opticalOutputScale: hdrReferenceWhiteNits)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system
|
||||
/// tone-map from the real grade. Called from the PUMP thread, so the layer write is hopped to MAIN
|
||||
/// (every CALayer mutation stays on one thread). The grade is cached so a later SDR→HDR
|
||||
/// `configureColor` re-applies it; the `edrMetadata` write is gated on `hdrActive` (setting it on an
|
||||
/// SDR layer is harmless but pointless, and the flip will apply it anyway).
|
||||
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.lastHdrMeta = meta
|
||||
// tvOS has no edrMetadata — the cached grade is still kept above (harmless), it just can't
|
||||
// be applied to the layer there. macOS/iOS refine the system tone-map from the real grade.
|
||||
#if !os(tvOS)
|
||||
if self.hdrActive { self.layer.edrMetadata = self.makeEDR(meta) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw one decoded frame to the next drawable and present it. MAIN THREAD (the display link).
|
||||
/// `isHDR` selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
||||
/// layer config via `configure`. Returns true on success; false when there's no drawable yet, a
|
||||
/// texture couldn't be made, or Metal errored — the caller then doesn't stamp a present.
|
||||
@discardableResult
|
||||
public func render(_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false) -> Bool {
|
||||
// Reconcile the layer with the decoded frame's HDR-ness (handles a mid-session SDR↔HDR flip).
|
||||
configure(hdr: isHDR)
|
||||
// P010 stores 10-bit luma/chroma in 16-bit samples → R16/RG16; NV12 is 8-bit → R8/RG8.
|
||||
let lumaFmt: MTLPixelFormat = isHDR ? .r16Unorm : .r8Unorm
|
||||
let chromaFmt: MTLPixelFormat = isHDR ? .rg16Unorm : .rg8Unorm
|
||||
|
||||
// P010/x444 store 10-bit luma/chroma in 16-bit samples → R16/RG16; NV12/444v is 8-bit → R8/RG8.
|
||||
// Derived from the actual decoded buffer so a 4:4:4 (full chroma plane) frame just works.
|
||||
let pf = CVPixelBufferGetPixelFormatType(pixelBuffer)
|
||||
let tenBit =
|
||||
pf == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
||||
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
||||
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
||||
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
||||
guard let textureCache,
|
||||
let luma = makeTexture(pixelBuffer, plane: 0, format: lumaFmt, cache: textureCache),
|
||||
let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache)
|
||||
let luma = makeTexture(
|
||||
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
|
||||
let chroma = makeTexture(
|
||||
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
||||
else { return false }
|
||||
|
||||
// The hosting view owns drawableSize (aspect-fit to its bounds); skip until it's laid
|
||||
// out. The fullscreen triangle scales the decoded texture to fill the drawable.
|
||||
guard layer.drawableSize.width > 0, layer.drawableSize.height > 0,
|
||||
let drawable = layer.nextDrawable(),
|
||||
// Size the drawable to the decoded frame so the fullscreen triangle samples 1:1 (pixel-exact);
|
||||
// the layer's contentsGravity then scales it to the on-screen bounds via the system compositor
|
||||
// (matching stage-1). drawableSize does NOT track bounds (defaults to 0), so set it BEFORE
|
||||
// nextDrawable; re-set only on a change (first frame / Reconfigure / HDR flip).
|
||||
let decodedSize = CGSize(
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
if layer.drawableSize != decodedSize { layer.drawableSize = decodedSize }
|
||||
#if DEBUG
|
||||
logSizeIfChanged(decoded: decodedSize)
|
||||
#endif
|
||||
guard let drawable = layer.nextDrawable(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
|
||||
@@ -186,24 +320,23 @@ public final class MetalVideoPresenter {
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
encoder.setRenderPipelineState(isHDR ? pipelineHDR : pipelineSDR)
|
||||
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
commandBuffer.present(drawable) // present at the next vsync — lowest latency
|
||||
// Hold the CVMetalTextures + the source pixel buffer (its IOSurface) alive until the GPU
|
||||
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
|
||||
// sampling — releasing them at scope exit could free the backing mid-read.
|
||||
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
||||
commandBuffer.commit()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past
|
||||
/// the draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
||||
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the
|
||||
/// draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
||||
private func makeTexture(
|
||||
_ pixelBuffer: CVPixelBuffer, plane: Int, format: MTLPixelFormat,
|
||||
cache: CVMetalTextureCache
|
||||
_ pixelBuffer: CVPixelBuffer, plane: Int, format: MTLPixelFormat, cache: CVMetalTextureCache
|
||||
) -> CVMetalTexture? {
|
||||
let w = CVPixelBufferGetWidthOfPlane(pixelBuffer, plane)
|
||||
let h = CVPixelBufferGetHeightOfPlane(pixelBuffer, plane)
|
||||
@@ -215,5 +348,16 @@ public final class MetalVideoPresenter {
|
||||
else { return nil }
|
||||
return cvTexture
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func logSizeIfChanged(decoded: CGSize) {
|
||||
let sig = "\(Int(decoded.width))x\(Int(decoded.height))|hdr\(hdrActive ? 1 : 0)"
|
||||
if sig != lastSizeSig {
|
||||
lastSizeSig = sig
|
||||
let msg = "stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) hdr=\(hdrActive)"
|
||||
presenterLog.info("\(msg, privacy: .public)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Steers the system's iPad pointer-lock resolution down to a chosen "anchor" view controller.
|
||||
//
|
||||
// `UIViewController.prefersPointerLocked` is resolved the same way as the status bar: the system
|
||||
// walks DOWN from the window's root view controller through `childViewControllerForPointerLock`.
|
||||
// SwiftUI's hosting / container view controllers do NOT forward that query to their children, so a
|
||||
// `UIViewControllerRepresentable` controller buried in the SwiftUI tree (our StreamViewController)
|
||||
// is never consulted — its `prefersPointerLocked = true` is silently ignored and a Magic Keyboard
|
||||
// trackpad / mouse falls through to the absolute-pointer path instead of being captured.
|
||||
//
|
||||
// Swizzling the DEFAULT implementation isn't enough: the controllers that break the chain
|
||||
// (UIHostingController and SwiftUI's internal containers) provide their OWN implementation of the
|
||||
// property, so a base-class swizzle never runs for them. Instead we walk UP the LIVE `parent`
|
||||
// chain from the anchor to the window root and, on each real ancestor, force
|
||||
// `childViewControllerForPointerLock` to return the next controller toward the anchor. Each forced
|
||||
// value is a genuine direct child (we follow the actual containment chain), so the system's
|
||||
// downward walk reaches the anchor and reads its `prefersPointerLocked`.
|
||||
//
|
||||
// The forcing is per-INSTANCE — an associated object — gated behind a one-time per-CLASS IMP
|
||||
// swizzle. Only the specific controllers in the anchor's chain are affected; every other instance
|
||||
// of those classes keeps its original behavior (associated object nil → original IMP). The forced
|
||||
// values are cleared on disengage so the long-lived SwiftUI parents don't retain a stale controller
|
||||
// across sessions. Only the PUBLIC `childViewControllerForPointerLock` selector is touched
|
||||
// (App-Store-safe; no private API).
|
||||
|
||||
#if os(iOS)
|
||||
import ObjectiveC
|
||||
import UIKit
|
||||
|
||||
enum PointerLockChain {
|
||||
private static var forcedChildKey: UInt8 = 0
|
||||
/// Classes whose `childViewControllerForPointerLock` we've already IMP-swizzled (keyed by the
|
||||
/// class object). Main-thread only — pointer-lock resolution and capture toggles are all main.
|
||||
private static var swizzledClasses = Set<ObjectIdentifier>()
|
||||
/// Ancestors we've stamped with a forced child this engagement, held weakly so a deallocated
|
||||
/// SwiftUI controller drops out on its own (no dangling). disengage() clears every one — even
|
||||
/// if the live `parent` chain has since broken — so a stamped parent can never retain a stale
|
||||
/// controller subtree across sessions. One anchor is ever engaged at a time.
|
||||
private static let stampedParents = NSHashTable<UIViewController>.weakObjects()
|
||||
|
||||
private static func forcedChild(of vc: UIViewController) -> UIViewController? {
|
||||
objc_getAssociatedObject(vc, &forcedChildKey) as? UIViewController
|
||||
}
|
||||
|
||||
private static func setForcedChild(_ child: UIViewController?, on vc: UIViewController) {
|
||||
// RETAIN: while steering, the parent must keep the toward-anchor child alive. It's also
|
||||
// already a strong child of `vc` via UIKit containment, so this adds no cycle (the reverse
|
||||
// `.parent` link is weak), and disengage() always clears it — so it can't outlive a session.
|
||||
objc_setAssociatedObject(vc, &forcedChildKey, child, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
|
||||
/// Ensure `cls`'s `childViewControllerForPointerLock` getter consults the per-instance forced
|
||||
/// child first, falling back to the class's original implementation. Idempotent per class.
|
||||
private static func ensureSwizzled(_ cls: AnyClass) {
|
||||
let id = ObjectIdentifier(cls)
|
||||
guard !swizzledClasses.contains(id) else { return }
|
||||
swizzledClasses.insert(id)
|
||||
let selector = #selector(getter: UIViewController.childViewControllerForPointerLock)
|
||||
guard let method = class_getInstanceMethod(cls, selector) else { return }
|
||||
let originalIMP = method_getImplementation(method)
|
||||
typealias OriginalFn = @convention(c) (AnyObject, Selector) -> UIViewController?
|
||||
let original = unsafeBitCast(originalIMP, to: OriginalFn.self)
|
||||
let forwarding: @convention(block) (UIViewController) -> UIViewController? = { vc in
|
||||
if let forced = forcedChild(of: vc) { return forced }
|
||||
return original(vc, selector)
|
||||
}
|
||||
method_setImplementation(method, imp_implementationWithBlock(forwarding))
|
||||
}
|
||||
|
||||
/// Force every ancestor of `anchor` to forward pointer-lock resolution toward it, then ask the
|
||||
/// system to re-resolve. No-op when `anchor` isn't in a view-controller hierarchy yet (it
|
||||
/// re-runs from the anchor's appearance/parent callbacks once it is).
|
||||
static func engage(_ anchor: UIViewController) {
|
||||
disengage(anchor) // clear any prior engagement first (reparent / re-anchor)
|
||||
var child = anchor
|
||||
while let parent = child.parent {
|
||||
ensureSwizzled(object_getClass(parent)!)
|
||||
setForcedChild(child, on: parent)
|
||||
stampedParents.add(parent)
|
||||
child = parent
|
||||
}
|
||||
anchor.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
|
||||
/// Clear the forced forwarding on every stamped ancestor (so the SwiftUI parents stop retaining
|
||||
/// the anchor's subtree) and re-resolve to drop the lock.
|
||||
static func disengage(_ anchor: UIViewController) {
|
||||
for parent in stampedParents.allObjects {
|
||||
setForcedChild(nil, on: parent)
|
||||
}
|
||||
stampedParents.removeAllObjects()
|
||||
anchor.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,36 @@
|
||||
// Synthetic 4:4:4 HEVC keyframes used only by `Stage444Probe` to probe decode capability.
|
||||
//
|
||||
// Each is the first IDR access unit (VPS + SPS + PPS + IDR slice, Annex-B) of a 256×256 HEVC
|
||||
// Range-Extensions clip — `chroma_format_idc = 3` — generated offline with libx265:
|
||||
// ffmpeg -f lavfi -i color=c=gray:s=256x256:r=30:d=0.1 -frames:v 3 \
|
||||
// -pix_fmt yuv444p[10le] -c:v libx265 \
|
||||
// -x265-params keyint=1:min-keyint=1:no-info=1:repeat-headers=1:aud=0 -f hevc out.hevc
|
||||
// 256×256 clears the hardware decoder's minimum-dimension floor (a 16×16 clip is rejected for every
|
||||
// chroma format). Validated to hardware-decode to `444v`/`x444` on Apple Silicon (M3).
|
||||
enum Probe444Blobs {
|
||||
/// 256×256 HEVC Range-Extensions 4:4:4 keyframe (Annex-B): 134 bytes.
|
||||
static let au444_8bit: [UInt8] = [
|
||||
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x04, 0x08, 0x00, 0x00, 0x03, 0x00,
|
||||
0x9e, 0x28, 0x00, 0x00, 0x03, 0x00, 0x00, 0x3c, 0xba, 0x02, 0x40, 0x00, 0x00, 0x00, 0x01, 0x42,
|
||||
0x01, 0x01, 0x04, 0x08, 0x00, 0x00, 0x03, 0x00, 0x9e, 0x28, 0x00, 0x00, 0x03, 0x00, 0x00, 0x3c,
|
||||
0x90, 0x01, 0x01, 0x00, 0x80, 0xb2, 0xdd, 0x49, 0x26, 0x57, 0x80, 0xb4, 0x04, 0x00, 0x00, 0x03,
|
||||
0x00, 0x04, 0x00, 0x00, 0x03, 0x00, 0x78, 0x20, 0x00, 0x00, 0x00, 0x01, 0x44, 0x01, 0xc1, 0x72,
|
||||
0x86, 0x0c, 0x06, 0x24, 0x00, 0x00, 0x00, 0x01, 0x28, 0x01, 0xaf, 0x72, 0x15, 0xe8, 0x34, 0xeb,
|
||||
0xae, 0xfb, 0xfe, 0x75, 0x57, 0xca, 0xc1, 0x71, 0x43, 0x16, 0xf5, 0xc2, 0x40, 0xbd, 0x80, 0xa6,
|
||||
0x65, 0x35, 0x20, 0x28, 0x81, 0xa2, 0x5e, 0xc0, 0x93, 0x04, 0x10, 0x9b, 0x00, 0x34, 0xe0, 0x87,
|
||||
0x00, 0x00, 0x03, 0x00, 0x5b, 0x40,
|
||||
]
|
||||
|
||||
/// 256×256 HEVC Range-Extensions 4:4:4 10-bit keyframe (Annex-B): 133 bytes.
|
||||
static let au444_10bit: [UInt8] = [
|
||||
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x04, 0x08, 0x00, 0x00, 0x03, 0x00,
|
||||
0x9c, 0x28, 0x00, 0x00, 0x03, 0x00, 0x00, 0x3c, 0xba, 0x02, 0x40, 0x00, 0x00, 0x00, 0x01, 0x42,
|
||||
0x01, 0x01, 0x04, 0x08, 0x00, 0x00, 0x03, 0x00, 0x9c, 0x28, 0x00, 0x00, 0x03, 0x00, 0x00, 0x3c,
|
||||
0x90, 0x01, 0x01, 0x00, 0x80, 0x9b, 0x2d, 0xd4, 0x92, 0x65, 0x78, 0x0b, 0x40, 0x40, 0x00, 0x00,
|
||||
0x03, 0x00, 0x40, 0x00, 0x00, 0x07, 0x82, 0x00, 0x00, 0x00, 0x01, 0x44, 0x01, 0xc1, 0x72, 0x86,
|
||||
0x0c, 0x06, 0x24, 0x00, 0x00, 0x00, 0x01, 0x28, 0x01, 0xaf, 0x72, 0x15, 0xe8, 0x34, 0xeb, 0xae,
|
||||
0xfb, 0xfe, 0x75, 0x57, 0xca, 0xc1, 0x71, 0x43, 0x16, 0xf5, 0xc2, 0x40, 0xbd, 0x80, 0xa6, 0x65,
|
||||
0x35, 0x20, 0x28, 0x81, 0xa2, 0x5e, 0xc0, 0x93, 0x04, 0x10, 0x9b, 0x00, 0x34, 0xe0, 0x87, 0x00,
|
||||
0x00, 0x03, 0x00, 0x5b, 0x40,
|
||||
]
|
||||
}
|
||||
@@ -182,6 +182,11 @@ public final class PunktfunkConnection {
|
||||
case dualSense = 2
|
||||
case xboxOne = 3
|
||||
case dualShock4 = 4
|
||||
// Valve Steam Controller / Steam Deck (Linux UHID hid-steam hosts). Parity only on Apple —
|
||||
// GameController never surfaces a 0x28DE HID device, so the client can't capture one; these
|
||||
// exist so the resolved type round-trips and name parsing matches the host.
|
||||
case steamController = 5
|
||||
case steamDeck = 6
|
||||
|
||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -192,6 +197,8 @@ public final class PunktfunkConnection {
|
||||
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
||||
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
@@ -231,6 +238,13 @@ public final class PunktfunkConnection {
|
||||
public private(set) var colorFullRange: Bool = false
|
||||
/// Encoded bit depth (8 or 10).
|
||||
public private(set) var bitDepth: UInt8 = 8
|
||||
/// The chroma subsampling the host resolved for this session, as the HEVC `chroma_format_idc`:
|
||||
/// `1` = 4:2:0 (every pre-4:4:4 host, and the back-compat default) or `3` = full-chroma 4:4:4
|
||||
/// (only when this client advertised `videoCap444` *and* the host could open a real 4:4:4
|
||||
/// encoder). Drive the decoder's requested pixel format from this. See `isChroma444`.
|
||||
public private(set) var chromaFormat: UInt8 = 1
|
||||
/// Convenience: the resolved stream is full-chroma 4:4:4 (`chroma_format_idc == 3`).
|
||||
public var isChroma444: Bool { chromaFormat == 3 }
|
||||
/// True when the negotiated stream is HDR (PQ or HLG transfer) — drive an HDR present path and
|
||||
/// drain `nextHdrMeta`.
|
||||
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
|
||||
@@ -327,6 +341,9 @@ public final class PunktfunkConnection {
|
||||
colorMatrix = mtx
|
||||
colorFullRange = fullRange != 0
|
||||
bitDepth = depth
|
||||
var cf: UInt8 = 1
|
||||
_ = punktfunk_connection_chroma_format(handle, &cf)
|
||||
chromaFormat = cf
|
||||
var ac: UInt8 = 2
|
||||
_ = punktfunk_connection_audio_channels(handle, &ac)
|
||||
resolvedAudioChannels = ac
|
||||
@@ -598,6 +615,10 @@ public final class PunktfunkConnection {
|
||||
public static let videoCap10Bit: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_10BIT)
|
||||
/// Video-capability bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
public static let videoCapHDR: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_HDR)
|
||||
/// Video-capability bit: the client can decode a full-chroma 4:4:4 HEVC stream (Range
|
||||
/// Extensions). Advertise only when the device can *hardware*-decode it (`Stage444Probe`);
|
||||
/// the host then emits 4:4:4 only if it too opted in. `chromaFormat` reflects the real value.
|
||||
public static let videoCap444: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_444)
|
||||
|
||||
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -177,6 +177,16 @@ public final class SessionAudio {
|
||||
private var playbackEngine: AVAudioEngine?
|
||||
private var captureEngine: AVAudioEngine?
|
||||
private var drainStarted = false
|
||||
#if !os(macOS)
|
||||
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||
/// they must not run on the main thread (UI stall — AVFoundation warns about it). PROCESS-WIDE
|
||||
/// (static) so every SessionAudio shares one serial queue: the AVAudioSession is a process
|
||||
/// singleton, and across a reconnect the old session's deactivate must be ordered before the
|
||||
/// new session's activate (a per-instance queue would let them race and leave the new session's
|
||||
/// audio deactivated). stop() enqueues its deactivate promptly so it lands before the next
|
||||
/// session's activate.
|
||||
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
||||
#endif
|
||||
|
||||
public init(connection: PunktfunkConnection) {
|
||||
self.connection = connection
|
||||
@@ -189,37 +199,60 @@ public final class SessionAudio {
|
||||
flag.stop()
|
||||
}
|
||||
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system
|
||||
/// default device; on iOS the UIDs are ignored entirely (routes are
|
||||
/// AVAudioSession-managed). Main thread (engine setup); returns after the engines
|
||||
/// start — the mic may start slightly later if the permission prompt is pending.
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
|
||||
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
#if os(iOS)
|
||||
// Route + policy live in the session, not per-engine: stereo playback, mic
|
||||
// capture when enabled, Bluetooth allowed. Failure is non-fatal (defaults).
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
// depend on the active session. A stop() racing in between is caught by the flag guard.
|
||||
Self.sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activateAudioSession(micEnabled: micEnabled)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
/// Route + policy live in the session, not per-engine: stereo playback, mic capture when
|
||||
/// enabled, Bluetooth allowed. Failure is non-fatal (defaults). Runs on `sessionQueue`.
|
||||
private func activateAudioSession(micEnabled: Bool) {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
#if os(iOS)
|
||||
if micEnabled {
|
||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone
|
||||
// EARPIECE; only affects the built-in route (headphones/BT still win).
|
||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
|
||||
// affects the built-in route (headphones/BT still win).
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
}
|
||||
#else // tvOS — no app-accessible mic
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
#endif
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
@@ -258,19 +291,24 @@ public final class SessionAudio {
|
||||
capture.stop()
|
||||
}
|
||||
playback?.stop()
|
||||
#if !os(macOS)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
|
||||
// activation, setActive is synchronous/blocking — run it on the shared serial session queue
|
||||
// (off the main thread). Enqueued HERE — engines already stopped, and BEFORE the drain wait
|
||||
// below — so across a reconnect it lands ahead of the next session's activate on the shared
|
||||
// queue (otherwise a deferred deactivate could deactivate the new session). Fire-and-forget.
|
||||
Self.sessionQueue.async {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(
|
||||
false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if wasDraining {
|
||||
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
|
||||
}
|
||||
#if !os(macOS)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its
|
||||
// resume cue.
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(
|
||||
false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
// Stage-2 presenter orchestrator: a pump thread pulls AUs → VideoDecoder; the decoder's async
|
||||
// output drops the newest decoded frame into a 1-slot ring; the hosting view's display link
|
||||
// calls `renderTick` once per vsync to draw + present the newest ready frame and stamp
|
||||
// capture→present. Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
|
||||
// Stage-2 presenter orchestrator: a pump thread pulls AUs → VideoDecoder; the decoder's async output
|
||||
// drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick`
|
||||
// once per vsync to draw + present the newest ready frame and stamp capture→present. Mirrors
|
||||
// StreamPump's lifecycle (one per start; cancel is permanent).
|
||||
//
|
||||
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick`
|
||||
// + `setDrawableSize` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there).
|
||||
// Only the ring + decoder cross threads and both are internally locked.
|
||||
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` +
|
||||
// `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). Only the ring (lock-guarded)
|
||||
// and the decoder/presenter (internally locked / main-hopped) cross threads.
|
||||
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import QuartzCore
|
||||
|
||||
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view
|
||||
/// directly makes a `view → link → view` cycle that only `invalidate()` breaks — if a teardown
|
||||
/// is ever missed the view leaks and keeps ticking. This proxy holds the handler weakly, so the
|
||||
/// view can deallocate and its `deinit` invalidate the link.
|
||||
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view directly
|
||||
/// makes a `view → link → view` cycle that only `invalidate()` breaks — if a teardown is ever missed
|
||||
/// the view leaks and keeps ticking. This proxy holds the handler weakly, so the view can deallocate
|
||||
/// and its `deinit` invalidate the link.
|
||||
public final class DisplayLinkProxy: NSObject {
|
||||
private let onTick: (CADisplayLink) -> Void
|
||||
public init(_ onTick: @escaping (CADisplayLink) -> Void) { self.onTick = onTick }
|
||||
@@ -44,10 +44,10 @@ private final class PumpToken: @unchecked Sendable {
|
||||
func cancel() { lock.lock(); live = false; lock.unlock() }
|
||||
}
|
||||
|
||||
/// Throttled host keyframe requests for decode recovery. The decoder's async error callback
|
||||
/// (a VT thread) and the pump thread (a submit failure) both signal a wedge; this coalesces
|
||||
/// them so the control stream isn't flooded while the decode stays stalled for several frames
|
||||
/// until the requested IDR lands. Bound to the live connection in `start`, unbound in `stop`.
|
||||
/// Throttled host keyframe requests for decode recovery. The decoder's async error callback (a VT
|
||||
/// thread) and the pump thread (a submit failure) both signal a wedge; this coalesces them so the
|
||||
/// control stream isn't flooded while the decode stays stalled for several frames until the requested
|
||||
/// IDR lands. Bound to the live connection in `start`, unbound in `stop`.
|
||||
private final class KeyframeRecovery: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var connection: PunktfunkConnection?
|
||||
@@ -60,7 +60,7 @@ private final class KeyframeRecovery: @unchecked Sendable {
|
||||
func request() {
|
||||
lock.lock()
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let due = lastNs == 0 || now &- lastNs > 250_000_000 // ≥ 250 ms since the last request
|
||||
let due = lastNs == 0 || now &- lastNs > 100_000_000 // ≥ 100 ms since the last request
|
||||
if due { lastNs = now }
|
||||
let conn = due ? connection : nil
|
||||
lock.unlock()
|
||||
@@ -76,30 +76,36 @@ public final class Stage2Pipeline {
|
||||
private let recovery = KeyframeRecovery()
|
||||
private var token = PumpToken()
|
||||
private var offsetNs: Int64 = 0
|
||||
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
||||
/// — otherwise a pump iteration already past its `token.isLive` check can rebuild a decode session
|
||||
/// right after the reset (a brief orphan session). `pumpJoinable` is armed by `start`, consumed by
|
||||
/// the first `stop` (so the idempotent second `stop`/deinit doesn't block on an already-drained
|
||||
/// semaphore). start/stop are sequential lifecycle calls, so the plain flag is safe.
|
||||
private let pumpStopped = DispatchSemaphore(value: 0)
|
||||
private var pumpJoinable = false
|
||||
|
||||
/// The Metal layer the hosting view installs + sizes. nil-init fails when Metal is
|
||||
/// unavailable so the caller can fall back to stage-1.
|
||||
/// The Metal layer the hosting view installs + sizes.
|
||||
public var layer: CAMetalLayer { presenter.layer }
|
||||
|
||||
/// `presentMeter` records capture→present (the glass-to-glass term). Returns nil if Metal
|
||||
/// can't be set up (headless / no GPU) — caller falls back to the stage-1 presenter.
|
||||
/// `presentMeter` records capture→present (the glass-to-glass term). Returns nil if Metal can't be
|
||||
/// set up (headless / no GPU) — caller falls back to the stage-1 presenter.
|
||||
public init?(presentMeter: LatencyMeter) {
|
||||
guard let presenter = MetalVideoPresenter() else { return nil }
|
||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||
self.presenter = presenter
|
||||
self.presentMeter = presentMeter
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
self.decoder = VideoDecoder(
|
||||
onDecoded: { ring.submit($0) },
|
||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump
|
||||
// resets to re-gate on the next IDR, and we ask the host to send one now (infinite
|
||||
// GOP — it wouldn't otherwise come soon). Throttled in KeyframeRecovery.
|
||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
||||
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
||||
// otherwise come soon). Throttled in KeyframeRecovery.
|
||||
onDecodeError: { _ in recovery.request() })
|
||||
}
|
||||
|
||||
/// Start pulling AUs into the decoder. `onFrame` fires per AU at receipt (capture→client
|
||||
/// meter, exactly as stage-1); `onSessionEnd` on close. `clockOffsetNs` (host minus client)
|
||||
/// makes the present stamp cross-machine valid.
|
||||
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (capture→client
|
||||
/// meter, exactly as stage-1); `onSessionEnd` on close. `clockOffsetNs` (host minus client) makes the
|
||||
/// present stamp cross-machine valid.
|
||||
public func start(
|
||||
connection: PunktfunkConnection,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
@@ -108,43 +114,70 @@ public final class Stage2Pipeline {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||
token = PumpToken() // fresh token per start — cancel is permanent (like StreamPump)
|
||||
|
||||
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
|
||||
// chroma subsampling drives only the decode pixel format (orthogonal to HDR/depth); the HDR
|
||||
// config is the Welcome's latched value, which a mid-session flip then overrides per-frame.
|
||||
decoder.setChroma444(connection.isChroma444)
|
||||
presenter.configure(hdr: connection.isHDR)
|
||||
|
||||
let token = token
|
||||
let decoder = decoder
|
||||
let recovery = recovery
|
||||
let presenter = presenter
|
||||
let pumpStopped = pumpStopped
|
||||
let thread = Thread {
|
||||
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
|
||||
// keep asking until an IDR lands so a request swallowed by the throttle is re-sent.
|
||||
var awaitingIDR = false
|
||||
// 4:4:4 backstop: a run of decode/create failures in a 4:4:4 session means this device can't
|
||||
// decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a
|
||||
// resolution-ceiling miss). End cleanly instead of looping on a black screen.
|
||||
var decodeFailRun = 0
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). The reassembler drops unrecoverable
|
||||
// AUs (framesDropped) and the decoder then conceals the reference-missing delta
|
||||
// frames that follow — often rendering them WITHOUT an error callback — so the
|
||||
// onDecodeError trigger rarely fires after a real network blip. Ask the host for
|
||||
// a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery).
|
||||
// Polled every iteration so a total-loss drought recovers the moment packets
|
||||
// resume and the reassembler counts the gap.
|
||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||
// so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh
|
||||
// IDR re-anchors decode.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
lastFramesDropped = dropped
|
||||
recovery.request()
|
||||
awaitingIDR = true
|
||||
}
|
||||
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
|
||||
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
|
||||
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||
decoder.setHdrMeta(meta)
|
||||
if awaitingIDR { recovery.request() }
|
||||
// Drain HDR mastering metadata (0xCE) and hand it to the PRESENTER (→ CAEDRMetadata).
|
||||
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
|
||||
// host sends 0xCE only for HDR, INCLUDING a mid-session SDR→HDR transition (a game
|
||||
// entering HDR — the host re-inits its encoder) the Welcome flag would never reflect.
|
||||
// Non-blocking; nil for an SDR stream.
|
||||
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||
presenter.setHdrMeta(meta)
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
guard let f = format, token.isLive else { continue }
|
||||
if !decoder.decode(au: au, format: f) {
|
||||
// Submit/decoder error: drop the session and re-gate on the next IDR's
|
||||
// in-band parameter sets (a delta frame can't recover) — stage-1's policy
|
||||
// — and ask the host for that IDR now (infinite GOP; throttled).
|
||||
if decoder.decode(au: au, format: f) {
|
||||
decodeFailRun = 0
|
||||
} else {
|
||||
// Submit/decoder error: drop the session and re-gate on the next IDR's in-band
|
||||
// parameter sets (a delta frame can't recover) and keep asking for that IDR.
|
||||
decoder.reset()
|
||||
recovery.request()
|
||||
awaitingIDR = true
|
||||
decodeFailRun += 1
|
||||
// ~3 s of solid failure in a 4:4:4 session (and only there — a 4:2:0 loss
|
||||
// recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session.
|
||||
if connection.isChroma444, decodeFailRun >= 180 {
|
||||
if token.isLive { onSessionEnd?() }
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if token.isLive { onSessionEnd?() }
|
||||
@@ -154,27 +187,30 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
thread.name = "punktfunk-stage2-pump"
|
||||
thread.qualityOfService = .userInteractive
|
||||
pumpJoinable = true
|
||||
thread.start()
|
||||
}
|
||||
|
||||
/// MAIN thread, once per vsync. Present the newest ready frame (if any) and stamp
|
||||
/// capture→present at `targetPresentNs` — the display link's target present instant, already
|
||||
/// converted to `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`).
|
||||
/// MAIN thread, once per vsync. Present the newest ready frame (if any) and stamp capture→present at
|
||||
/// `targetPresentNs` — the display link's target present instant, already converted to
|
||||
/// `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`).
|
||||
public func renderTick(targetPresentNs: Int64) {
|
||||
guard let frame = ring.take() else { return }
|
||||
guard presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) else { return }
|
||||
presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs)
|
||||
}
|
||||
|
||||
/// MAIN thread. Keep the drawable matched to the negotiated mode (host can Reconfigure).
|
||||
public func setDrawableSize(_ size: CGSize) {
|
||||
presenter.setDrawableSize(size)
|
||||
}
|
||||
|
||||
/// Stop the pump (≤ one poll timeout) and drop the decode session. Does not close the
|
||||
/// connection. A restart needs a fresh Stage2Pipeline (cancel is permanent).
|
||||
/// Stop the pump (≤ one poll timeout) and drop the decode session. MAIN THREAD; idempotent. Does not
|
||||
/// close the connection. A restart needs a fresh Stage2Pipeline (cancel is permanent).
|
||||
public func stop() {
|
||||
token.cancel()
|
||||
// Join the pump (bounded: ≤ one nextAU poll + an in-flight decode) before resetting the decoder,
|
||||
// so the pump can't rebuild a session right after the reset. Only the first stop joins; a
|
||||
// repeat/deinit stop skips the already-drained semaphore.
|
||||
if pumpJoinable {
|
||||
pumpJoinable = false
|
||||
_ = pumpStopped.wait(timeout: .now() + 0.5)
|
||||
}
|
||||
decoder.reset()
|
||||
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
||||
}
|
||||
@@ -182,8 +218,8 @@ public final class Stage2Pipeline {
|
||||
deinit { token.cancel() }
|
||||
|
||||
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
||||
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the
|
||||
/// target present time (when the frame is actually on glass), not the moment we drew.
|
||||
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target
|
||||
/// present time (when the frame is actually on glass), not the moment we drew.
|
||||
public static func realtimeNs(forDisplayLinkTimestamp t: CFTimeInterval) -> Int64 {
|
||||
let caNow = CACurrentMediaTime()
|
||||
var ts = timespec()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Runtime 4:4:4 HEVC decode-capability probe.
|
||||
//
|
||||
// We advertise `VIDEO_CAP_444` (so the host upgrades to a full-chroma 4:4:4 stream) ONLY when this
|
||||
// device can decode 4:4:4 HEVC *in hardware* — software 4:4:4 decode works but is far too slow for a
|
||||
// real-time stream at the negotiated resolution, so a software-only device must keep 4:2:0.
|
||||
//
|
||||
// `VTIsHardwareDecodeSupported(HEVC)` and the HEVC-decoder-capabilities dictionary report HEVC HW
|
||||
// decode but expose nothing about `chroma_format_idc`, so the only reliable signal is to actually
|
||||
// create a *hardware-required* `VTDecompressionSession` for a tiny synthetic 4:4:4 keyframe and
|
||||
// confirm it both creates and decodes to the expected biplanar 4:4:4 pixel format. Validated on an
|
||||
// Apple M3 (HW 4:4:4 8- and 10-bit decode to `444v`/`x444`); a software-only decoder fails the
|
||||
// hardware-required create and we fall back to 4:2:0.
|
||||
//
|
||||
// The probe blobs are 256×256 (above the hardware decoder's minimum-dimension floor — a 16×16 clip
|
||||
// is rejected for ALL chroma formats, including 4:2:0) HEVC Range-Extensions keyframes generated
|
||||
// offline with libx265; see scripts notes. Results are cached (device-static) in lazy statics.
|
||||
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import VideoToolbox
|
||||
|
||||
public enum Stage444Probe {
|
||||
/// True iff this device hardware-decodes 8-bit 4:4:4 HEVC (the host's current 4:4:4 path —
|
||||
/// BT.709 limited `yuv444p`). Cached after first evaluation.
|
||||
public static let hwDecode444_8bit: Bool = probeHardware444(
|
||||
au: Probe444Blobs.au444_8bit,
|
||||
want: kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange,
|
||||
fullRangeSibling: kCVPixelFormatType_444YpCbCr8BiPlanarFullRange)
|
||||
|
||||
/// True iff this device hardware-decodes 10-bit 4:4:4 HEVC (the 4:4:4 ∩ HDR/10-bit intersection).
|
||||
/// Cached after first evaluation.
|
||||
public static let hwDecode444_10bit: Bool = probeHardware444(
|
||||
au: Probe444Blobs.au444_10bit,
|
||||
want: kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange,
|
||||
fullRangeSibling: kCVPixelFormatType_444YpCbCr10BiPlanarFullRange)
|
||||
|
||||
/// Create a hardware-REQUIRED `VTDecompressionSession` for the synthetic 4:4:4 keyframe and
|
||||
/// decode it, returning true only when the decoder produces the expected (video- or full-range)
|
||||
/// biplanar 4:4:4 pixel format. Any failure (no hardware path, wrong output format, decode error)
|
||||
/// → false → we keep 4:2:0.
|
||||
private static func probeHardware444(
|
||||
au auBytes: [UInt8], want: OSType, fullRangeSibling: OSType
|
||||
) -> Bool {
|
||||
let data = Data(auBytes)
|
||||
guard let format = AnnexB.formatDescription(fromIDR: data) else { return false }
|
||||
// Require a hardware decoder — a software false-positive would make us advertise 4:4:4 and
|
||||
// then decode every real frame on the CPU, blowing the latency budget.
|
||||
let spec: [CFString: Any] = [
|
||||
kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder: true,
|
||||
]
|
||||
let attrs: [CFString: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey: want,
|
||||
kCVPixelBufferMetalCompatibilityKey: true,
|
||||
]
|
||||
var session: VTDecompressionSession?
|
||||
let created = VTDecompressionSessionCreate(
|
||||
allocator: kCFAllocatorDefault, formatDescription: format,
|
||||
decoderSpecification: spec as CFDictionary, imageBufferAttributes: attrs as CFDictionary,
|
||||
outputCallback: nil, decompressionSessionOut: &session)
|
||||
guard created == noErr, let session else { return false }
|
||||
defer { VTDecompressionSessionInvalidate(session) }
|
||||
|
||||
let au = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0)
|
||||
guard let sample = AnnexB.sampleBuffer(au: au, format: format) else { return false }
|
||||
|
||||
var produced: OSType = 0
|
||||
let done = DispatchSemaphore(value: 0)
|
||||
let status = VTDecompressionSessionDecodeFrame(
|
||||
session, sampleBuffer: sample,
|
||||
flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
|
||||
) { status, _, imageBuffer, _, _ in
|
||||
if status == noErr, let imageBuffer {
|
||||
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||
}
|
||||
done.signal()
|
||||
}
|
||||
guard status == noErr else { return false }
|
||||
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
||||
_ = done.wait(timeout: .now() + 1.0)
|
||||
return produced == want || produced == fullRangeSibling
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video")
|
||||
|
||||
/// Cancellation handle owned by exactly one pump thread — a restart hands the old pump
|
||||
/// its own token, so it can never be revived by a newer start().
|
||||
@@ -47,44 +50,74 @@ final class StreamPump {
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastKeyframeRequest = Date.distantPast
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Coalesced host keyframe request: the decode stays wedged for several frames until
|
||||
// the IDR lands, so requesting on every frame would flood the control stream.
|
||||
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
||||
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
|
||||
// fresh IDR actually re-anchors decode. The old code advanced `lastFramesDropped` on the
|
||||
// same edge it fired the throttled request — so a request swallowed by the throttle (a
|
||||
// second drop within the window, e.g. the lost recovery IDR itself being pruned) was
|
||||
// never re-sent: the counter went flat, the climb never re-fired, and the picture stayed
|
||||
// frozen for good while audio kept playing. The iPhone's lossy Wi-Fi hits this where the
|
||||
// Mac's Ethernet never does.
|
||||
var awaitingIDR = false
|
||||
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
||||
var wasFailed = false
|
||||
// Coalesced host keyframe request. 100 ms throttle (matches the working Android path):
|
||||
// fast enough that a lost recovery IDR is re-requested promptly, bounded so a sustained
|
||||
// freeze can't flood the control stream.
|
||||
func requestKeyframeThrottled() {
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.1 {
|
||||
connection.requestKeyframe()
|
||||
lastKeyframeRequest = now
|
||||
}
|
||||
}
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). Under the host's infinite GOP the
|
||||
// only recovery keyframe is one we request. The reassembler drops unrecoverable
|
||||
// AUs (framesDropped); the decoder then *conceals* the reference-missing delta
|
||||
// frames that follow — a frozen / garbage picture, WITHOUT flipping the layer to
|
||||
// .failed — so the .failed check below rarely fires after a real network blip.
|
||||
// Ask the host for a fresh IDR whenever the drop count climbs. Polled every
|
||||
// iteration (not just per AU) so a total-loss drought still recovers the moment
|
||||
// packets resume and the reassembler counts the gap.
|
||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||
// frozen / garbage picture that never flips the layer to .failed — so key off the
|
||||
// drop count climbing, then keep asking (awaitingIDR) until an IDR lands. Polled
|
||||
// every iteration so a total-loss drought still recovers when packets resume.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
// Log only on the false→true transition (once per recovery cycle), not per
|
||||
// dropped AU, so heavy loss doesn't spam the log.
|
||||
if !awaitingIDR {
|
||||
awaitingSince = Date()
|
||||
pumpLog.notice(
|
||||
"video: unrecoverable drop (framesDropped=\(dropped, privacy: .public)) — requesting recovery IDR")
|
||||
}
|
||||
lastFramesDropped = dropped
|
||||
requestKeyframeThrottled()
|
||||
awaitingIDR = true
|
||||
}
|
||||
if awaitingIDR { requestKeyframeThrottled() }
|
||||
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
let idrFormat = AnnexB.formatDescription(fromIDR: au.data)
|
||||
if let f = idrFormat {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
if awaitingIDR {
|
||||
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
||||
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if layer.status == .failed {
|
||||
let failed = layer.status == .failed
|
||||
if failed {
|
||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||
// IDR): flush and re-gate on the next in-band parameter sets (resuming with
|
||||
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled:
|
||||
// the layer stays .failed across several polls until the IDR lands.
|
||||
// IDR): flush and, unless THIS AU is the recovering IDR (re-anchored above),
|
||||
// re-gate on the next in-band parameter sets and keep asking — enqueuing a
|
||||
// delta into a failed layer can't recover it.
|
||||
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
||||
layer.flush()
|
||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
||||
requestKeyframeThrottled()
|
||||
if idrFormat == nil {
|
||||
format = nil
|
||||
awaitingIDR = true
|
||||
}
|
||||
}
|
||||
wasFailed = failed
|
||||
guard let f = format,
|
||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||
token.isLive // don't enqueue a stale frame after a restart
|
||||
|
||||
@@ -137,8 +137,8 @@ public struct StreamView: NSViewRepresentable {
|
||||
public final class StreamLayerView: NSView {
|
||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||
private var pump: StreamPump?
|
||||
/// Stage-2 presenter (opt-in via `punktfunk.presenter`): a CAMetalLayer sublayer driven by a
|
||||
/// display link instead of the StreamPump → displayLayer path. nil = stage-1 (default).
|
||||
/// Stage-2 presenter (default): a CAMetalLayer sublayer driven by a display link instead of the
|
||||
/// StreamPump → displayLayer path. nil = stage-1 (Metal-unavailable fallback / DEBUG toggle).
|
||||
var presentMeter: LatencyMeter?
|
||||
private var stage2: Stage2Pipeline?
|
||||
private var stage2Link: CADisplayLink?
|
||||
@@ -245,6 +245,15 @@ public final class StreamLayerView: NSView {
|
||||
layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view
|
||||
}
|
||||
|
||||
public override func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
// `layout()` isn't guaranteed on a manual-frame (no-Auto-Layout) live resize, so the
|
||||
// stage-2 metal sublayer's drawableSize could stay at the old size while the view grows —
|
||||
// the compositor then upscales a too-small drawable and the video turns blocky. Resize the
|
||||
// drawable here too so it always tracks the window's pixel size (no stale upscale).
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
// MARK: - Capture state machine
|
||||
|
||||
/// Clicking into the video engages capture; that click is local (engagement), so
|
||||
@@ -549,10 +558,17 @@ public final class StreamLayerView: NSView {
|
||||
cursorVisible = false
|
||||
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
||||
|
||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
||||
// (`punktfunk.presenter == "stage2"`) takes explicit VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present; it falls back here if Metal can't be set up.
|
||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
||||
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is
|
||||
// reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1
|
||||
// pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||
#else
|
||||
let forceStage1 = false
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let meter = presentMeter,
|
||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -593,9 +609,11 @@ public final class StreamLayerView: NSView {
|
||||
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
||||
}
|
||||
|
||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
||||
/// so this is usually the full bounds; it letterboxes a resized window). drawableSize is the
|
||||
/// layer's pixel size — the fullscreen-triangle shader scales the decoded texture to fill it.
|
||||
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
|
||||
/// mode, so this is usually the full bounds; it letterboxes a resized window). Only the layer
|
||||
/// FRAME is set here — the presenter sizes the drawable to the decoded frame and the layer's
|
||||
/// contentsGravity (.resizeAspect) scales it to this frame via the system compositor, so a
|
||||
/// resized window rescales through the system's filter (matching stage-1) instead of the shader.
|
||||
private func layoutMetalLayer() {
|
||||
guard let metalLayer, let connection else { return }
|
||||
let mode = connection.currentMode()
|
||||
@@ -604,14 +622,12 @@ public final class StreamLayerView: NSView {
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
: bounds
|
||||
let scale = window?.backingScaleFactor ?? 1
|
||||
// No implicit resize animation; refresh contentsScale on a retina↔non-retina move.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
metalLayer.contentsScale = scale
|
||||
metalLayer.contentsScale = window?.backingScaleFactor ?? 1
|
||||
metalLayer.frame = fit
|
||||
CATransaction.commit()
|
||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
||||
}
|
||||
|
||||
public override func viewDidChangeBackingProperties() {
|
||||
@@ -622,7 +638,7 @@ public final class StreamLayerView: NSView {
|
||||
private func teardownStage2() {
|
||||
stage2Link?.invalidate()
|
||||
stage2Link = nil
|
||||
stage2?.stop()
|
||||
stage2?.stop() // stops the pump (synchronous join) + drops the decode session
|
||||
stage2 = nil
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
|
||||
@@ -11,13 +11,18 @@
|
||||
// host mode, so the host's rescale is the identity).
|
||||
//
|
||||
// A hardware mouse/trackpad is a pointer, not a finger. When the scene is pointer-LOCKED
|
||||
// (full-screen + frontmost iPad) GCMouse delivers raw relative deltas and the system hides
|
||||
// the cursor — the gaming-grade path. When it CAN'T lock (Stage Manager, not frontmost,
|
||||
// iPhone) the system shows its own cursor and routes the mouse through UIKit's pointer path:
|
||||
// hover + indirect-pointer touches, which we forward as ABSOLUTE cursor position (+ buttons)
|
||||
// so the host cursor tracks the visible local one. We never forward an indirect pointer as a
|
||||
// touch — doing so hid the cursor and made the host see taps instead of a moving mouse.
|
||||
// GCMouse is gated off whenever the lock isn't held so the two paths can't double-send.
|
||||
// (full-screen + frontmost iPad, and the user hasn't disabled pointer capture in Settings —
|
||||
// see PointerLockChain, which steers the lock request through SwiftUI's hosting controllers)
|
||||
// GCMouse delivers raw relative deltas and the system hides the cursor — the gaming-grade path.
|
||||
// InputCapture handles EVERY connected mouse (GCMouse.mice), not just the current one, so a
|
||||
// trackpad + a second pointer (e.g. a Universal Control mouse) both drive. When the scene CAN'T
|
||||
// lock (Stage Manager, not frontmost, iPhone, capture disabled) the system shows its own cursor
|
||||
// and routes the mouse through UIKit's pointer path: hover + indirect-pointer touches, which we
|
||||
// forward as ABSOLUTE cursor position (+ buttons) so the host cursor tracks the visible local one.
|
||||
// We never forward an indirect pointer as a touch — doing so hid the cursor and made the host see
|
||||
// taps instead of a moving mouse. The two paths are mutually exclusive on `gcMouseForwarding`
|
||||
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
|
||||
// scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send.
|
||||
// Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming
|
||||
// starts, ⌘⎋ toggles (detected from the HID stream; there is no NSEvent monitor here).
|
||||
//
|
||||
@@ -92,8 +97,8 @@ public final class StreamViewController: UIViewController {
|
||||
public private(set) var connection: PunktfunkConnection?
|
||||
private var pump: StreamPump?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
/// Stage-2 presenter (opt-in via `punktfunk.presenter`): a CAMetalLayer sublayer driven by a
|
||||
/// CADisplayLink instead of the StreamPump → displayLayer path. nil = stage-1 (default).
|
||||
/// Stage-2 presenter (default): a CAMetalLayer sublayer driven by a CADisplayLink instead of the
|
||||
/// StreamPump → displayLayer path. nil = stage-1 (Metal-unavailable fallback / DEBUG toggle).
|
||||
var presentMeter: LatencyMeter?
|
||||
private var stage2: Stage2Pipeline?
|
||||
private var stage2Link: CADisplayLink?
|
||||
@@ -136,6 +141,13 @@ public final class StreamViewController: UIViewController {
|
||||
|
||||
public override func loadView() {
|
||||
view = StreamLayerUIView()
|
||||
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
|
||||
// moving to an external display at a different scale) — the iOS analogue of macOS's
|
||||
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
|
||||
// doesn't capture self (no retain cycle with the registration).
|
||||
registerForTraitChanges([UITraitDisplayScale.self]) { (vc: StreamViewController, _) in
|
||||
vc.layoutMetalLayer()
|
||||
}
|
||||
#if os(iOS)
|
||||
// Hide the iPadOS cursor while it hovers the video: the host renders its own
|
||||
// cursor from our deltas, so the local one only diverges from it. This hides the
|
||||
@@ -148,19 +160,58 @@ public final class StreamViewController: UIViewController {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// Pointer lock is only meaningful on iPad (iPhone has no hardware-pointer lock) and
|
||||
// only when capture is engaged. The system additionally requires full-screen + frontmost
|
||||
// and may drop it (Slide Over/Stage Manager/backgrounding) — verified in setCaptured().
|
||||
public override var prefersPointerLocked: Bool {
|
||||
captured && UIDevice.current.userInterfaceIdiom == .pad
|
||||
/// Whether the user wants the mouse/trackpad pointer CAPTURED (pointer lock → relative
|
||||
/// movement, the gaming default) rather than forwarded as an absolute position (desktop
|
||||
/// use). Read live from UserDefaults so it tracks the Settings toggle; defaults to on when
|
||||
/// unset. iPad-only — gated again in `prefersPointerLocked`.
|
||||
private var pointerCaptureEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: DefaultsKey.pointerCapture) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// Whether the pointer should be CAPTURED right now: iPad, capture engaged, and the user
|
||||
/// hasn't opted into the absolute (desktop) pointer. The system additionally requires
|
||||
/// full-screen + frontmost and may drop the lock (Slide Over/Stage Manager/backgrounding) —
|
||||
/// syncPointerLock() handles the actual grant/drop and falls back to absolute when unlocked.
|
||||
private var wantsPointerLock: Bool {
|
||||
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock }
|
||||
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||
|
||||
// If SwiftUI's UIHostingController reparents us, a plain container parent that forwards
|
||||
// its pointer-lock decision to its children will then reach this VC. (UIHostingController
|
||||
// itself does not consult children, which is why GCMouse deltas can never arrive there —
|
||||
// the touch path, always forwarded, is the unconditional fallback.)
|
||||
public override var childViewControllerForPointerLock: UIViewController? { self }
|
||||
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
|
||||
// returns nil, which tells the system to use THIS controller's own `prefersPointerLocked` —
|
||||
// exactly what we want, since `PointerLockChain` forces our SwiftUI ancestors to forward the
|
||||
// downward walk to us and we are the terminal anchor. Returning `self` here would make the
|
||||
// system ask the same controller forever (it keeps delegating to the returned child) →
|
||||
// unbounded recursion → stack overflow once the chain actually reaches us.
|
||||
|
||||
/// (Re)build or tear down the forced pointer-lock forwarding chain from this controller to the
|
||||
/// window root so the system actually resolves our `prefersPointerLocked`. Safe to call
|
||||
/// repeatedly — it no-ops until the view is in a window with a parent chain, and re-runs from
|
||||
/// the appearance/parent callbacks once SwiftUI has placed us.
|
||||
private func updatePointerLockChain() {
|
||||
// Engaging needs a live parent chain to the window root; disengaging is always safe and
|
||||
// must run even after the view has left the window (session teardown) so the stamped
|
||||
// SwiftUI ancestors are cleared.
|
||||
if wantsPointerLock, view.window != nil {
|
||||
PointerLockChain.engage(self)
|
||||
} else {
|
||||
PointerLockChain.disengage(self)
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
// SwiftUI places us in the hierarchy AFTER start()'s setCaptured(true), and may reparent us
|
||||
// later — re-anchor the chain here so a lock requested before we had a parent still lands.
|
||||
updatePointerLockChain()
|
||||
}
|
||||
|
||||
public override func didMove(toParent parent: UIViewController?) {
|
||||
super.didMove(toParent: parent)
|
||||
updatePointerLockChain() // chain shape changed — re-anchor (or no-op if not yet in a window)
|
||||
}
|
||||
#endif
|
||||
|
||||
func start(
|
||||
@@ -190,18 +241,22 @@ public final class StreamViewController: UIViewController {
|
||||
guard self?.captureEnabled == true else { return }
|
||||
connection?.send(event)
|
||||
}
|
||||
// Indirect pointer (mouse/trackpad with no lock) → absolute cursor + buttons, routed
|
||||
// through InputCapture so the forwarding gate and release-on-blur apply uniformly.
|
||||
// Indirect pointer (mouse/trackpad) WITHOUT a lock → absolute cursor + buttons + scroll.
|
||||
// While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
|
||||
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
|
||||
// mouse BOTH report through GCMouse under lock and ALSO emit UIKit indirect-pointer events
|
||||
// (pinned at the locked position) — without this gate a click double-sends (GCMouse + UIKit)
|
||||
// and a second pointer (e.g. a Universal Control mouse) competes with the trackpad. The gate
|
||||
// is the exact mirror of the GCMouse handlers, which fire only while locked.
|
||||
streamView.onPointerMoveAbs = { [weak self] p in
|
||||
self?.inputCapture?.sendMouseAbs(
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseAbs(
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
streamView.onPointerButton = { [weak self] button, down in
|
||||
self?.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
}
|
||||
// Trackpad two-finger / wheel scroll → host scroll. The pan recognizer is the
|
||||
// UNLOCKED regime; while locked, GCMouse's scroll handler owns it — mirror the
|
||||
// sendMouseAbs !gcMouseForwarding gate so the two can't double-send.
|
||||
streamView.onScroll = { [weak self] dx, dy in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendScroll(dx: dx, dy: dy)
|
||||
@@ -219,10 +274,17 @@ public final class StreamViewController: UIViewController {
|
||||
inputCapture = capture
|
||||
#endif
|
||||
|
||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
||||
// (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present; falls back here if Metal can't be set up.
|
||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
||||
// Presenter choice — stage-2 is the DEFAULT (VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder, where
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no
|
||||
// way to recover. Stage-1 is reachable only via the DEBUG presenter toggle; release always
|
||||
// takes stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||
#else
|
||||
let forceStage1 = false
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let meter = presentMeter,
|
||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -300,8 +362,8 @@ public final class StreamViewController: UIViewController {
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
|
||||
) {
|
||||
let metal = pipeline.layer
|
||||
metal.contentsScale = streamView.contentScaleFactor
|
||||
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
|
||||
// (contentsScale + frame are set by layoutMetalLayer() just below.)
|
||||
streamView.layer.addSublayer(metal)
|
||||
metalLayer = metal
|
||||
stage2 = pipeline
|
||||
@@ -325,9 +387,20 @@ public final class StreamViewController: UIViewController {
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
||||
/// so this is usually the full bounds). drawableSize is the layer's pixel size; the shader's
|
||||
/// fullscreen triangle scales the decoded texture to fill it.
|
||||
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
|
||||
/// canonical render scale and is reliable once the controller is in the hierarchy;
|
||||
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
|
||||
/// would size the drawable at point resolution → a pixelated, upscaled mess. Falls back to the
|
||||
/// main screen scale if the trait is still unspecified.
|
||||
private var renderScale: CGFloat {
|
||||
let s = traitCollection.displayScale
|
||||
return s > 0 ? s : UIScreen.main.scale
|
||||
}
|
||||
|
||||
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
|
||||
/// mode, so this is usually the full bounds). Only the layer FRAME is set here — the presenter
|
||||
/// sizes the drawable to the decoded frame and the layer's contentsGravity (.resizeAspect)
|
||||
/// scales it to this frame via the system compositor (matching stage-1's videoGravity).
|
||||
private func layoutMetalLayer() {
|
||||
guard let metalLayer, let connection else { return }
|
||||
let mode = connection.currentMode()
|
||||
@@ -337,19 +410,17 @@ public final class StreamViewController: UIViewController {
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
: bounds
|
||||
let scale = streamView.contentScaleFactor
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true) // don't animate the resize
|
||||
metalLayer.contentsScale = scale
|
||||
metalLayer.contentsScale = renderScale
|
||||
metalLayer.frame = fit
|
||||
CATransaction.commit()
|
||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
||||
}
|
||||
|
||||
private func teardownStage2() {
|
||||
stage2Link?.invalidate()
|
||||
stage2Link = nil
|
||||
stage2?.stop()
|
||||
stage2?.stop() // stops the pump (synchronous join) + drops the decode session
|
||||
stage2 = nil
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
@@ -369,6 +440,7 @@ public final class StreamViewController: UIViewController {
|
||||
captured = false
|
||||
}
|
||||
setNeedsUpdateOfPrefersPointerLocked()
|
||||
updatePointerLockChain() // (re)anchor the SwiftUI ancestors so the lock actually resolves
|
||||
syncPointerLock() // resolve cursor + GCMouse/absolute routing for the current state
|
||||
let onCaptureChange = onCaptureChange
|
||||
let captured = captured
|
||||
|
||||
@@ -49,11 +49,10 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
/// pump can re-gate on the next IDR.
|
||||
private let onDecodeError: @Sendable (OSStatus) -> Void
|
||||
|
||||
/// Latest source HDR mastering metadata (from `PunktfunkConnection.nextHdrMeta`), attached to
|
||||
/// each decoded HDR pixel buffer so the compositor tone-maps from the real grade. Guarded by its
|
||||
/// own lock — written by the pump thread, read on the VT decode callback.
|
||||
private let metaLock = NSLock()
|
||||
private var hdrMeta: PunktfunkConnection.HdrMeta?
|
||||
/// Whether the negotiated stream is full-chroma 4:4:4 (`connection.isChroma444`), set once at
|
||||
/// session start before any decode. Selects the 4:4:4 decode pixel format (orthogonal to bit
|
||||
/// depth / HDR). Read inside `createSessionLocked` under `lock`.
|
||||
private var chroma444 = false
|
||||
|
||||
public init(
|
||||
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
|
||||
@@ -65,12 +64,13 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
|
||||
deinit { teardown() }
|
||||
|
||||
/// Set the source HDR mastering metadata (drained from `PunktfunkConnection.nextHdrMeta`). It's
|
||||
/// attached to subsequent decoded HDR pixel buffers. Thread-safe; cheap to call on each update.
|
||||
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
|
||||
metaLock.lock()
|
||||
hdrMeta = meta
|
||||
metaLock.unlock()
|
||||
/// Select the chroma subsampling of the decode output (4:2:0 vs full-chroma 4:4:4). Call once at
|
||||
/// session start, before decoding, from `connection.isChroma444`. Takes effect on the next
|
||||
/// session (re)build. Thread-safe.
|
||||
public func setChroma444(_ on: Bool) {
|
||||
lock.lock()
|
||||
chroma444 = on
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
||||
@@ -135,8 +135,10 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
|
||||
/// True when `newFormat` carries a PQ (SMPTE ST 2084) or HLG transfer function — i.e. the host
|
||||
/// is sending HDR (BT.2020). VideoToolbox populates the transfer-function extension from the
|
||||
/// HEVC VUI, so this tracks the *stream*, switching dynamically when the user toggles HDR
|
||||
/// (the host re-emits parameter sets with the new VUI → a new format desc → session rebuild).
|
||||
/// HEVC VUI, so this picks the decode bit depth (10-bit P010/x444 vs 8-bit NV12/444v) from the
|
||||
/// stream. The present-side HDR config (colorspace/EDR/shader) is latched once per session from
|
||||
/// the Welcome (`connection.isHDR`), which the host does NOT flip mid-session — so this predicate
|
||||
/// and that config agree for the session (a `#if DEBUG` assert in the presenter guards it).
|
||||
static func isHDRFormat(_ format: CMVideoFormatDescription) -> Bool {
|
||||
guard
|
||||
let tf = CMFormatDescriptionGetExtension(
|
||||
@@ -157,11 +159,18 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
session = nil
|
||||
format = nil
|
||||
|
||||
// Decode pixel format is a 2×2 of (chroma, depth/HDR), both biplanar so the presenter binds
|
||||
// plane 0 = luma, plane 1 = interleaved chroma uniformly — 4:4:4 just delivers a full-size
|
||||
// chroma plane. 10-bit (P010 / `x444`) for HDR (PQ/HLG), 8-bit (NV12 / `444v`) otherwise.
|
||||
let hdr = Self.isHDRFormat(newFormat)
|
||||
let pixelFormat =
|
||||
hdr
|
||||
? kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange // P010 (10-bit)
|
||||
: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange // NV12 (8-bit)
|
||||
let pixelFormat: OSType = {
|
||||
switch (chroma444, hdr) {
|
||||
case (false, false): return kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange // NV12
|
||||
case (false, true): return kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange // P010
|
||||
case (true, false): return kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange // 444v
|
||||
case (true, true): return kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange // x444
|
||||
}
|
||||
}()
|
||||
let imageAttrs: [CFString: Any] = [
|
||||
kCVPixelBufferMetalCompatibilityKey: true,
|
||||
kCVPixelBufferPixelFormatTypeKey: pixelFormat,
|
||||
@@ -169,11 +178,20 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
var callback = VTDecompressionOutputCallbackRecord(
|
||||
decompressionOutputCallback: decoderOutputCallback,
|
||||
decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque())
|
||||
// 4:4:4 sessions REQUIRE a hardware decoder: we only advertise 4:4:4 when the hardware probe
|
||||
// passed, so a hardware-incapable mode (e.g. a resolution past the HW 4:4:4 ceiling) must fail
|
||||
// HERE, synchronously, letting the pump's backstop end the session — rather than silently
|
||||
// falling back to a software 4:4:4 decoder far too slow for a real-time stream. 4:2:0 keeps the
|
||||
// software fallback (nil spec) as a robustness net.
|
||||
let spec: CFDictionary? =
|
||||
chroma444
|
||||
? [kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder: true] as CFDictionary
|
||||
: nil
|
||||
var newSession: VTDecompressionSession?
|
||||
let status = VTDecompressionSessionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
formatDescription: newFormat,
|
||||
decoderSpecification: nil, // hardware by default
|
||||
decoderSpecification: spec,
|
||||
imageBufferAttributes: imageAttrs as CFDictionary,
|
||||
outputCallback: &callback,
|
||||
decompressionSessionOut: &newSession)
|
||||
@@ -195,26 +213,17 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
// pts was stamped at timescale 1e9 (AnnexB.sampleBuffer); normalize defensively.
|
||||
let p = CMTimeConvertScale(pts, timescale: 1_000_000_000, method: .default)
|
||||
let ptsNs = p.value > 0 ? UInt64(p.value) : 0
|
||||
// HDR iff the decoder produced a 10-bit P010 buffer (we only request P010 for PQ streams).
|
||||
// HDR iff the decoder produced a 10-bit buffer (we only request a 10-bit format for PQ/HLG
|
||||
// streams). Covers 4:2:0 (P010) and 4:4:4 (`x444`), video- and full-range, so a 10-bit 4:4:4
|
||||
// HDR frame isn't misclassified as SDR. (The mastering metadata is applied to the presenter's
|
||||
// CAMetalLayer via CAEDRMetadata, not to this source buffer — a separate-drawable presenter
|
||||
// never composites the source buffer's attachments, so attaching them here would be dead.)
|
||||
let fmt = CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||
let isHDR =
|
||||
CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||
== kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
||||
// Attach the source's mastering display + content light level (ST.2086 / CEA-861.3) so the
|
||||
// compositor tone-maps from the real grade rather than inferring from the PQ colourspace
|
||||
// alone. The SEI byte payloads map 1:1 to these CVImageBuffer attachment keys.
|
||||
if isHDR {
|
||||
metaLock.lock()
|
||||
let meta = hdrMeta
|
||||
metaLock.unlock()
|
||||
if let meta {
|
||||
CVBufferSetAttachment(
|
||||
imageBuffer, kCVImageBufferMasteringDisplayColorVolumeKey,
|
||||
meta.masteringDisplayColorVolume() as CFData, .shouldPropagate)
|
||||
CVBufferSetAttachment(
|
||||
imageBuffer, kCVImageBufferContentLightLevelInfoKey,
|
||||
meta.contentLightLevelInfo() as CFData, .shouldPropagate)
|
||||
}
|
||||
}
|
||||
fmt == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
||||
|| fmt == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
||||
|| fmt == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
||||
|| fmt == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
||||
onDecoded(
|
||||
ReadyFrame(ptsNs: ptsNs, decodedNs: decodedNs, pixelBuffer: imageBuffer, isHDR: isHDR))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import XCTest
|
||||
|
||||
#if canImport(Metal)
|
||||
import CoreVideo
|
||||
import Metal
|
||||
import QuartzCore
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class MetalPresenterTests: XCTestCase {
|
||||
/// `MetalVideoPresenter.make()` compiles the runtime Metal shaders (the BT.709/BT.2020 YUV→RGB
|
||||
/// fragment shaders plus the Catmull-Rom luma sampler). A `nil` result on a GPU-equipped host
|
||||
/// means a shader failed to compile — this catches a malformed shader before it silently
|
||||
/// degrades stage-2 to a stage-1 fallback on device.
|
||||
func testPresenterInitCompilesShaders() throws {
|
||||
guard MTLCreateSystemDefaultDevice() != nil else {
|
||||
throw XCTSkip("no Metal device available in this environment")
|
||||
}
|
||||
XCTAssertNotNil(
|
||||
MetalVideoPresenter.make(),
|
||||
"stage-2 Metal shaders failed to compile (presenter init returned nil)")
|
||||
}
|
||||
|
||||
/// The HDR fix: `configure(hdr:)` must put the layer into the BT.2020-PQ EDR configuration with a
|
||||
/// reference-white anchor (`edrMetadata`) — the missing anchor was what made HDR render "too
|
||||
/// bright". SDR must use the plain 8-bit path with EDR off and no metadata. A mid-session flip is a
|
||||
/// per-mode reconfigure, so the round trip back to SDR must fully restore the SDR config.
|
||||
func testConfigureHDRSetsEDRAnchor() throws {
|
||||
guard let presenter = MetalVideoPresenter.make() else {
|
||||
throw XCTSkip("no Metal device available in this environment")
|
||||
}
|
||||
presenter.configure(hdr: true)
|
||||
XCTAssertEqual(presenter.layer.pixelFormat, .rgba16Float, "HDR uses an EDR-capable drawable")
|
||||
XCTAssertNotNil(presenter.layer.colorspace, "HDR layer must be tagged (itur_2100_PQ)")
|
||||
XCTAssertTrue(
|
||||
presenter.layer.wantsExtendedDynamicRangeContent, "EDR must be requested on all platforms")
|
||||
XCTAssertNotNil(
|
||||
presenter.layer.edrMetadata,
|
||||
"HDR must anchor reference white via edrMetadata (the fix for 'too bright')")
|
||||
|
||||
// Mid-session HDR→SDR flip: the 8-bit path, EDR off, no metadata.
|
||||
presenter.configure(hdr: false)
|
||||
XCTAssertEqual(presenter.layer.pixelFormat, .bgra8Unorm, "SDR uses the plain 8-bit drawable")
|
||||
XCTAssertFalse(presenter.layer.wantsExtendedDynamicRangeContent)
|
||||
XCTAssertNil(presenter.layer.edrMetadata)
|
||||
}
|
||||
|
||||
/// `render` with a freshly-allocated NV12 buffer must present without crashing or hanging — the
|
||||
/// main-thread present path is the highest-risk part of the stage-2 rewrite. (A headless CI with no
|
||||
/// display can still allocate a drawable from a CAMetalLayer; if it can't, render returns false,
|
||||
/// which is also a valid non-crashing outcome.)
|
||||
func testRenderDoesNotCrashOnNV12Frame() throws {
|
||||
guard let presenter = MetalVideoPresenter.make() else {
|
||||
throw XCTSkip("no Metal device available in this environment")
|
||||
}
|
||||
presenter.configure(hdr: false)
|
||||
var pb: CVPixelBuffer?
|
||||
let attrs: [CFString: Any] = [kCVPixelBufferMetalCompatibilityKey: true]
|
||||
let status = CVPixelBufferCreate(
|
||||
kCFAllocatorDefault, 256, 256, kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
|
||||
attrs as CFDictionary, &pb)
|
||||
guard status == kCVReturnSuccess, let pixelBuffer = pb else {
|
||||
throw XCTSkip("could not allocate a test pixel buffer")
|
||||
}
|
||||
// Just asserting it returns (true or false) without trapping — the layer may have no drawable
|
||||
// source headless, so a false return is acceptable.
|
||||
_ = presenter.render(pixelBuffer, isHDR: false)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,68 @@
|
||||
// 4:4:4 decode-path coverage: the hardware-capability probe is stable/cached, and a real 4:4:4 HEVC
|
||||
// keyframe decodes through VideoDecoder to a biplanar 4:4:4 pixel buffer. Reuses the same synthetic
|
||||
// 4:4:4 blobs the runtime probe ships with.
|
||||
|
||||
import CoreVideo
|
||||
import VideoToolbox
|
||||
import XCTest
|
||||
@testable import PunktfunkKit
|
||||
|
||||
private final class FrameBox: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var frame: ReadyFrame?
|
||||
var error: OSStatus?
|
||||
}
|
||||
|
||||
final class Stage444Tests: XCTestCase {
|
||||
/// The capability probe is device-static and cached — reading it twice must return the same value
|
||||
/// (and must never crash, including where 4:4:4 is unsupported → false).
|
||||
func testProbeIsStableAndCached() {
|
||||
XCTAssertEqual(Stage444Probe.hwDecode444_8bit, Stage444Probe.hwDecode444_8bit)
|
||||
XCTAssertEqual(Stage444Probe.hwDecode444_10bit, Stage444Probe.hwDecode444_10bit)
|
||||
}
|
||||
|
||||
/// A real 8-bit 4:4:4 HEVC keyframe (the embedded probe blob) decodes through `VideoDecoder` with
|
||||
/// `setChroma444(true)` to a 256×256 biplanar 4:4:4 (`444v`/`444f`) buffer classified SDR.
|
||||
/// (4:4:4 sessions require a hardware decoder — skip where there isn't one, which is exactly where
|
||||
/// the client wouldn't advertise 4:4:4 anyway.)
|
||||
func testVideoDecoderDecodes444() throws {
|
||||
try XCTSkipUnless(
|
||||
Stage444Probe.hwDecode444_8bit, "no hardware 4:4:4 decode on this device")
|
||||
let data = Data(Probe444Blobs.au444_8bit)
|
||||
let format = try XCTUnwrap(
|
||||
AnnexB.formatDescription(fromIDR: data), "the 4:4:4 blob must yield a format description")
|
||||
let au = AccessUnit(data: data, ptsNs: 7_000_000, frameIndex: 0, flags: 0)
|
||||
|
||||
let box = FrameBox()
|
||||
let done = DispatchSemaphore(value: 0)
|
||||
let decoder = VideoDecoder(
|
||||
onDecoded: { f in box.lock.lock(); box.frame = f; box.lock.unlock(); done.signal() },
|
||||
onDecodeError: { s in box.lock.lock(); box.error = s; box.lock.unlock(); done.signal() })
|
||||
decoder.setChroma444(true)
|
||||
|
||||
XCTAssertTrue(decoder.decode(au: au, format: format), "4:4:4 frame submit should succeed")
|
||||
XCTAssertEqual(done.wait(timeout: .now() + 10), .success, "the decode callback must fire")
|
||||
decoder.reset()
|
||||
|
||||
box.lock.lock(); let frame = box.frame; let error = box.error; box.lock.unlock()
|
||||
XCTAssertNil(error.map { "decode error \($0)" })
|
||||
let ready = try XCTUnwrap(frame, "a 4:4:4 ReadyFrame must be delivered")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 256)
|
||||
let pf = CVPixelBufferGetPixelFormatType(ready.pixelBuffer)
|
||||
XCTAssertTrue(
|
||||
pf == kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange
|
||||
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange,
|
||||
"expected a biplanar 4:4:4 8-bit buffer, got \(fourCCString(pf))")
|
||||
XCTAssertFalse(ready.isHDR, "an 8-bit BT.709 4:4:4 stream is SDR")
|
||||
// The chroma plane (plane 1) must be FULL resolution for 4:4:4 (vs half for 4:2:0) — this is
|
||||
// what lets the unchanged shader sample chroma at the luma UV.
|
||||
XCTAssertEqual(CVPixelBufferGetWidthOfPlane(ready.pixelBuffer, 1), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeightOfPlane(ready.pixelBuffer, 1), 256)
|
||||
}
|
||||
|
||||
private func fourCCString(_ t: OSType) -> String {
|
||||
let b = [UInt8(t >> 24 & 0xff), UInt8(t >> 16 & 0xff), UInt8(t >> 8 & 0xff), UInt8(t & 0xff)]
|
||||
return String(bytes: b, encoding: .ascii) ?? "\(t)"
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,13 @@ const RESOLUTIONS: [number, number, string][] = [
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
const GAMEPADS = ["auto", "xbox360", "dualsense"];
|
||||
const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
dualsense: "DualSense",
|
||||
steamdeck: "Steam Deck",
|
||||
};
|
||||
|
||||
const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
@@ -355,14 +361,17 @@ const SettingsSection: FC = () => {
|
||||
/>
|
||||
<Field label="Gamepad type" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({
|
||||
data: g,
|
||||
label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense",
|
||||
}))}
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
{s.gamepad === "steamdeck" && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
checked={s.mic_enabled}
|
||||
|
||||
@@ -113,12 +113,35 @@ async function ensureShortcut(): Promise<number> {
|
||||
return appId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the
|
||||
* Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient
|
||||
* API for this, so it is feature-detected and MUST never block or throw into the launch — the manual
|
||||
* toggle (game page → ⚙ → Controller Settings → Steam Input Off, surfaced in the plugin Settings) is
|
||||
* the documented source of truth. No-op when the optional API is absent.
|
||||
*/
|
||||
function disableSteamInputForShortcut(appId: number): void {
|
||||
try {
|
||||
const input = (
|
||||
SteamClient as unknown as {
|
||||
Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void };
|
||||
}
|
||||
).Input;
|
||||
input?.SetSteamInputEnabledForApp?.(appId, false);
|
||||
} catch {
|
||||
/* a controller tweak must never break the launch */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the
|
||||
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
|
||||
*/
|
||||
export async function launchStream(host: string, port: number): Promise<void> {
|
||||
const appId = await ensureShortcut();
|
||||
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
|
||||
// disables Steam Input manually — see the Settings instruction).
|
||||
disableSteamInputForShortcut(appId);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
||||
|
||||
@@ -767,6 +767,7 @@ fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>,
|
||||
connector,
|
||||
frames.take().expect("Connected delivered once"),
|
||||
app.gamepad.escape_events(),
|
||||
app.gamepad.disconnect_events(),
|
||||
handle.stop.clone(),
|
||||
inhibit,
|
||||
&title,
|
||||
|
||||
+186
-32
@@ -18,7 +18,7 @@ use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
|
||||
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
|
||||
@@ -33,8 +33,15 @@ const G: f32 = 9.80665;
|
||||
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
|
||||
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
|
||||
/// is leaving anyway); we only also raise the escape signal.
|
||||
///
|
||||
/// **Escalation:** a quick press leaves fullscreen / releases capture; *holding* the same
|
||||
/// chord for [`DISCONNECT_HOLD`] ends the session. Deliberately NOT the Steam / QAM buttons —
|
||||
/// those are the marquee pass-through controls that now reach the host's game-mode UI.
|
||||
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
|
||||
|
||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
pub id: u32,
|
||||
@@ -58,6 +65,7 @@ impl PadInfo {
|
||||
GamepadPref::DualSense => "DualSense",
|
||||
GamepadPref::DualShock4 => "DualShock 4",
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
GamepadPref::SteamDeck => "Steam Deck",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
@@ -89,6 +97,9 @@ pub struct GamepadService {
|
||||
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
||||
/// fullscreen + release capture.
|
||||
escape_rx: async_channel::Receiver<()>,
|
||||
/// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page
|
||||
/// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D).
|
||||
disconnect_rx: async_channel::Receiver<()>,
|
||||
}
|
||||
|
||||
impl GamepadService {
|
||||
@@ -98,11 +109,12 @@ impl GamepadService {
|
||||
let pinned = Arc::new(Mutex::new(None));
|
||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
|
||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
|
||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) {
|
||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||
}
|
||||
})
|
||||
@@ -115,6 +127,7 @@ impl GamepadService {
|
||||
pinned,
|
||||
ctl,
|
||||
escape_rx,
|
||||
disconnect_rx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +137,12 @@ impl GamepadService {
|
||||
self.escape_rx.clone()
|
||||
}
|
||||
|
||||
/// A receiver that yields one `()` when the escape chord is held past [`DISCONNECT_HOLD`]
|
||||
/// (controller disconnect). A fresh clone per call; the stream page spawns a future on it.
|
||||
pub fn disconnect_events(&self) -> async_channel::Receiver<()> {
|
||||
self.disconnect_rx.clone()
|
||||
}
|
||||
|
||||
pub fn pads(&self) -> Vec<PadInfo> {
|
||||
self.pads.lock().unwrap().clone()
|
||||
}
|
||||
@@ -188,6 +207,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
||||
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
||||
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button.
|
||||
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
|
||||
Button::RightPaddle1 => wire::BTN_PADDLE1,
|
||||
Button::LeftPaddle1 => wire::BTN_PADDLE2,
|
||||
Button::RightPaddle2 => wire::BTN_PADDLE3,
|
||||
Button::LeftPaddle2 => wire::BTN_PADDLE4,
|
||||
Button::Misc1 => wire::BTN_MISC1,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -259,11 +285,22 @@ struct Worker {
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
held_buttons: Vec<u32>,
|
||||
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
|
||||
/// switch / detach so a contact held at that moment doesn't stick. surface 0 = the legacy single
|
||||
/// touchpad, 1/2 = a Steam left/right pad.
|
||||
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||
last_accel: [i16; 3],
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
/// Raises the UI disconnect signal when the escape chord is held past [`DISCONNECT_HOLD`].
|
||||
disconnect_tx: async_channel::Sender<()>,
|
||||
/// The escape chord is fully held — latched so it fires once, not every poll.
|
||||
chord_armed: bool,
|
||||
/// When the escape chord became fully held (drives the hold-to-disconnect escalation); `None`
|
||||
/// when the chord is broken.
|
||||
chord_since: Option<Instant>,
|
||||
/// The disconnect signal already fired for the current hold — latched so it fires once.
|
||||
disconnect_fired: bool,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
@@ -275,13 +312,22 @@ impl Worker {
|
||||
|
||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||
let pad = self.opened.get(&id)?;
|
||||
let mut pref = pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
);
|
||||
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
|
||||
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
|
||||
// hid-steam pad with the back grips + dual trackpads and the right glyph identity.
|
||||
if pad.vendor_id() == Some(0x28DE)
|
||||
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
||||
{
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
),
|
||||
pref,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -297,32 +343,90 @@ impl Worker {
|
||||
}
|
||||
*v = i32::MIN;
|
||||
}
|
||||
// Lift any touchpad contact the host still believes is down (surface 0 = legacy pad).
|
||||
for (surface, finger) in self.held_touches.drain() {
|
||||
let rich = if surface == 0 {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: false,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
}
|
||||
} else {
|
||||
self.held_buttons.clear();
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.held_touches.clear();
|
||||
}
|
||||
// A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too.
|
||||
self.reset_chord();
|
||||
}
|
||||
|
||||
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
|
||||
/// fires once per press). Called after each button-down updates `held_buttons`.
|
||||
/// fires once per press) and start the hold-to-disconnect timer. Called after each
|
||||
/// button-down updates `held_buttons`.
|
||||
fn maybe_fire_escape(&mut self) {
|
||||
if self.chord_armed {
|
||||
return;
|
||||
}
|
||||
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||
self.chord_armed = true;
|
||||
self.chord_since = Some(Instant::now());
|
||||
let _ = self.escape_tx.try_send(());
|
||||
tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen");
|
||||
tracing::info!(
|
||||
"gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen (hold to disconnect)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the disconnect signal once the escape chord has been continuously held past
|
||||
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
|
||||
fn maybe_fire_disconnect(&mut self) {
|
||||
if self.disconnect_fired {
|
||||
return;
|
||||
}
|
||||
if let Some(since) = self.chord_since {
|
||||
if since.elapsed() >= DISCONNECT_HOLD {
|
||||
self.disconnect_fired = true;
|
||||
let _ = self.disconnect_tx.try_send(());
|
||||
tracing::info!("gamepad escape chord held — disconnecting");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-arm once the chord is broken (any of its buttons released).
|
||||
fn rearm_escape(&mut self) {
|
||||
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||
self.chord_armed = false;
|
||||
self.reset_chord();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the escape/disconnect chord latches. Called at every session boundary
|
||||
/// ([`flush_held`](Self::flush_held) on detach/pad-switch + on attach): the hold-to-disconnect
|
||||
/// path *always* ends the session while the chord is still physically held, so the matching
|
||||
/// button-up events arrive after detach (dropped by the `attached` guard) and `rearm_escape`
|
||||
/// never runs — without this the latched state would leak into the next session and either
|
||||
/// swallow its first chord press or fire a stale disconnect on connect.
|
||||
fn reset_chord(&mut self) {
|
||||
self.chord_armed = false;
|
||||
self.chord_since = None;
|
||||
self.disconnect_fired = false;
|
||||
}
|
||||
|
||||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||
fn set_sensors(&mut self, enabled: bool) {
|
||||
let Some(id) = self.active_id() else { return };
|
||||
@@ -335,6 +439,56 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
|
||||
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
|
||||
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
|
||||
fn forward_touch(
|
||||
&mut self,
|
||||
which: u32,
|
||||
touchpad: u32,
|
||||
finger: u8,
|
||||
x: f32,
|
||||
y: f32,
|
||||
active: bool,
|
||||
) {
|
||||
let Some(c) = self.attached.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let multi = self
|
||||
.opened
|
||||
.get(&which)
|
||||
.map(|p| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false);
|
||||
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||
let rich = if multi {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: active,
|
||||
click: false,
|
||||
x: (cx * 65535.0 - 32768.0) as i16,
|
||||
y: (cy * 65535.0 - 32768.0) as i16,
|
||||
pressure: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active,
|
||||
x: (cx * 65535.0) as u16,
|
||||
y: (cy * 65535.0) as u16,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
if active {
|
||||
self.held_touches.insert((surface, finger));
|
||||
} else {
|
||||
self.held_touches.remove(&(surface, finger));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -344,11 +498,18 @@ fn run(
|
||||
pinned_out: &Mutex<Option<u32>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
escape_tx: &async_channel::Sender<()>,
|
||||
disconnect_tx: &async_channel::Sender<()>,
|
||||
) -> Result<(), String> {
|
||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||
// own thread.
|
||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. On a Deck in Game
|
||||
// Mode, Steam Input still holds the device — the user must disable Steam Input for this app (see
|
||||
// the Decky UX); on a desktop client (or a Deck with Steam Input off) the hints just work.
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
|
||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||
@@ -361,9 +522,13 @@ fn run(
|
||||
attached: None,
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: std::collections::HashSet::new(),
|
||||
last_accel: [0; 3],
|
||||
escape_tx: escape_tx.clone(),
|
||||
disconnect_tx: disconnect_tx.clone(),
|
||||
chord_armed: false,
|
||||
chord_since: None,
|
||||
disconnect_fired: false,
|
||||
};
|
||||
|
||||
let publish = |w: &Worker| {
|
||||
@@ -381,6 +546,7 @@ fn run(
|
||||
Ok(Ctl::Attach(c)) => {
|
||||
w.attached = Some(c);
|
||||
w.last_axis = [i32::MIN; 6];
|
||||
w.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||
w.set_sensors(true);
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
@@ -474,9 +640,11 @@ fn run(
|
||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||
}
|
||||
}
|
||||
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
|
||||
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||
Event::ControllerTouchpadDown {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
@@ -484,41 +652,23 @@ fn run(
|
||||
}
|
||||
| Event::ControllerTouchpadMotion {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: true,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||
}
|
||||
Event::ControllerTouchpadUp {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: false,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||
}
|
||||
// Motion: accel events update the cache; each gyro event ships a sample
|
||||
// (the DualSense reports both at ~250 Hz). Scale convention shared with
|
||||
@@ -559,6 +709,10 @@ fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
|
||||
// new button events; the chord itself is only detected while a session is attached).
|
||||
w.maybe_fire_disconnect();
|
||||
|
||||
// Feedback planes (this thread is their single consumer). The host re-sends
|
||||
// rumble state periodically, so a generous duration with refresh-on-update is
|
||||
// safe — a dropped stop heals within ~500 ms.
|
||||
|
||||
@@ -124,12 +124,13 @@ impl Capture {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
window: &adw::ApplicationWindow,
|
||||
connector: Arc<NativeClient>,
|
||||
frames: async_channel::Receiver<DecodedFrame>,
|
||||
escape_rx: async_channel::Receiver<()>,
|
||||
disconnect_rx: async_channel::Receiver<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
inhibit_shortcuts: bool,
|
||||
title: &str,
|
||||
@@ -152,7 +153,7 @@ pub fn new(
|
||||
stats_label.set_margin_top(12);
|
||||
|
||||
let hint = gtk::Label::new(Some(
|
||||
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases",
|
||||
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects",
|
||||
));
|
||||
hint.add_css_class("osd");
|
||||
hint.set_halign(gtk::Align::Center);
|
||||
@@ -163,7 +164,9 @@ pub fn new(
|
||||
// Flashed when entering fullscreen — the only exit affordances once the header bar is
|
||||
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
||||
// only way out on a Steam Deck).
|
||||
let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen"));
|
||||
let fs_hint = gtk::Label::new(Some(
|
||||
"F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)",
|
||||
));
|
||||
fs_hint.add_css_class("osd");
|
||||
fs_hint.set_halign(gtk::Align::Center);
|
||||
fs_hint.set_valign(gtk::Align::Start);
|
||||
@@ -297,6 +300,7 @@ pub fn new(
|
||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||
let cap = capture.clone();
|
||||
let window_k = window.clone();
|
||||
let stop_kb = stop.clone();
|
||||
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
||||
let chord = gdk::ModifierType::CONTROL_MASK
|
||||
| gdk::ModifierType::ALT_MASK
|
||||
@@ -309,6 +313,13 @@ pub fn new(
|
||||
}
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
||||
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
|
||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||
cap.release();
|
||||
stop_kb.store(true, Ordering::SeqCst);
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
if keyval == gdk::Key::F11 {
|
||||
if window_k.is_fullscreen() {
|
||||
window_k.unfullscreen();
|
||||
@@ -442,6 +453,24 @@ pub fn new(
|
||||
})
|
||||
};
|
||||
|
||||
// Controller disconnect (escape chord held past the hold threshold) → end the session, the
|
||||
// controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump, which pops
|
||||
// this page (and fires `hidden` below). One-shot — the session is going away.
|
||||
let disconnect_future = {
|
||||
let window = window.clone();
|
||||
let cap = capture.clone();
|
||||
let stop_d = stop.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if disconnect_rx.recv().await.is_ok() {
|
||||
cap.release();
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
}
|
||||
stop_d.store(true, Ordering::SeqCst);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// The page's `hidden` fires once navigation away completes (back button, pop on
|
||||
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
||||
{
|
||||
@@ -449,6 +478,7 @@ pub fn new(
|
||||
let stop_h = stop.clone();
|
||||
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
||||
let escape_future = RefCell::new(Some(escape_future));
|
||||
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||
page.connect_hidden(move |_| {
|
||||
tracing::debug!("stream page hidden — ending session");
|
||||
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
||||
@@ -458,6 +488,9 @@ pub fn new(
|
||||
if let Some(f) = escape_future.borrow_mut().take() {
|
||||
f.abort();
|
||||
}
|
||||
if let Some(f) = disconnect_future.borrow_mut().take() {
|
||||
f.abort();
|
||||
}
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
}
|
||||
|
||||
+108
-27
@@ -169,6 +169,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
||||
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
||||
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button.
|
||||
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
|
||||
Button::RightPaddle1 => wire::BTN_PADDLE1,
|
||||
Button::LeftPaddle1 => wire::BTN_PADDLE2,
|
||||
Button::RightPaddle2 => wire::BTN_PADDLE3,
|
||||
Button::LeftPaddle2 => wire::BTN_PADDLE4,
|
||||
Button::Misc1 => wire::BTN_MISC1,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -240,6 +247,9 @@ struct Worker {
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
held_buttons: Vec<u32>,
|
||||
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
|
||||
/// switch / detach. surface 0 = the legacy single touchpad, 1/2 = a Steam left/right pad.
|
||||
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||
last_accel: [i16; 3],
|
||||
}
|
||||
|
||||
@@ -252,13 +262,21 @@ impl Worker {
|
||||
|
||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||
let pad = self.opened.get(&id)?;
|
||||
let mut pref = pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
);
|
||||
// No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205,
|
||||
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
|
||||
if pad.vendor_id() == Some(0x28DE)
|
||||
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
||||
{
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
),
|
||||
pref,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -274,9 +292,33 @@ impl Worker {
|
||||
}
|
||||
*v = i32::MIN;
|
||||
}
|
||||
for (surface, finger) in self.held_touches.drain() {
|
||||
let rich = if surface == 0 {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: false,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
}
|
||||
} else {
|
||||
self.held_buttons.clear();
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.held_touches.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +334,56 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
|
||||
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
|
||||
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
|
||||
fn forward_touch(
|
||||
&mut self,
|
||||
which: u32,
|
||||
touchpad: u32,
|
||||
finger: u8,
|
||||
x: f32,
|
||||
y: f32,
|
||||
active: bool,
|
||||
) {
|
||||
let Some(c) = self.attached.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let multi = self
|
||||
.opened
|
||||
.get(&which)
|
||||
.map(|p| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false);
|
||||
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||
let rich = if multi {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: active,
|
||||
click: false,
|
||||
x: (cx * 65535.0 - 32768.0) as i16,
|
||||
y: (cy * 65535.0 - 32768.0) as i16,
|
||||
pressure: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active,
|
||||
x: (cx * 65535.0) as u16,
|
||||
y: (cy * 65535.0) as u16,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
if active {
|
||||
self.held_touches.insert((surface, finger));
|
||||
} else {
|
||||
self.held_touches.remove(&(surface, finger));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -305,6 +397,10 @@ fn run(
|
||||
// thread.
|
||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs.
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
|
||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||
@@ -317,6 +413,7 @@ fn run(
|
||||
attached: None,
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: std::collections::HashSet::new(),
|
||||
last_accel: [0; 3],
|
||||
};
|
||||
|
||||
@@ -426,9 +523,11 @@ fn run(
|
||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||
}
|
||||
}
|
||||
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
|
||||
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||
Event::ControllerTouchpadDown {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
@@ -436,41 +535,23 @@ fn run(
|
||||
}
|
||||
| Event::ControllerTouchpadMotion {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: true,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||
}
|
||||
Event::ControllerTouchpadUp {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: false,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||
}
|
||||
// Motion: accel events update the cache; each gyro event ships a sample (the
|
||||
// DualSense reports both at ~250 Hz). Scale convention shared with the other
|
||||
|
||||
@@ -80,7 +80,14 @@ pub mod control {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: u32,
|
||||
pub _reserved: u32,
|
||||
/// Host-preferred per-client monitor id (`1..=15`) — the EDID serial / IddCx `ConnectorIndex` /
|
||||
/// `ContainerId` the driver names this monitor by. A given client (keyed by its cert fingerprint)
|
||||
/// gets a STABLE id across reconnects, so the OS device path + EDID stay identical and Windows
|
||||
/// reapplies that client's saved per-monitor config (DPI scaling). `0` = AUTO: the driver
|
||||
/// allocates the lowest-free id (the original slot-based behavior — used for anonymous/TOFU and
|
||||
/// GameStream sessions). Byte-compatible with the old `_reserved` (offset 20): an un-upgraded
|
||||
/// driver ignores it (→ auto), which the host detects via [`AddReply::resolved_monitor_id`].
|
||||
pub preferred_monitor_id: u32,
|
||||
}
|
||||
|
||||
/// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to
|
||||
@@ -91,7 +98,11 @@ pub mod control {
|
||||
pub adapter_luid_low: u32,
|
||||
pub adapter_luid_high: i32,
|
||||
pub target_id: u32,
|
||||
pub _reserved: u32,
|
||||
/// The monitor id the driver ACTUALLY used — echoes [`AddRequest::preferred_monitor_id`] when the
|
||||
/// preference was honored, or the auto-allocated id otherwise. Byte-compatible with the old
|
||||
/// `_reserved` (offset 12): an un-upgraded driver leaves it `0`, so the host can tell its
|
||||
/// preference was ignored (stale driver) and log it instead of silently losing per-client config.
|
||||
pub resolved_monitor_id: u32,
|
||||
}
|
||||
|
||||
/// `IOCTL_REMOVE` input.
|
||||
@@ -129,11 +140,13 @@ pub mod control {
|
||||
assert!(offset_of!(AddRequest, width) == 8);
|
||||
assert!(offset_of!(AddRequest, height) == 12);
|
||||
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
||||
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
|
||||
|
||||
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!(offset_of!(AddReply, resolved_monitor_id) == 12);
|
||||
|
||||
assert!(size_of::<RemoveRequest>() == 8);
|
||||
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||
@@ -436,11 +449,25 @@ mod tests {
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
refresh_hz: 120,
|
||||
_reserved: 0,
|
||||
preferred_monitor_id: 7,
|
||||
};
|
||||
let bytes = bytemuck::bytes_of(&req);
|
||||
assert_eq!(bytes.len(), 24);
|
||||
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
|
||||
// preferred_monitor_id occupies the old `_reserved` slot at offset 20 — byte-compatible.
|
||||
assert_eq!(bytes[20..24], 7u32.to_le_bytes());
|
||||
|
||||
let reply = control::AddReply {
|
||||
adapter_luid_low: 0x1234_5678,
|
||||
adapter_luid_high: -2,
|
||||
target_id: 262,
|
||||
resolved_monitor_id: 7,
|
||||
};
|
||||
let rbytes = bytemuck::bytes_of(&reply);
|
||||
assert_eq!(rbytes.len(), 16);
|
||||
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
|
||||
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
|
||||
assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -492,6 +492,10 @@ pub const PUNKTFUNK_HIDOUT_LED: u8 = 1;
|
||||
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
||||
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
||||
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||
/// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` =
|
||||
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
@@ -559,6 +563,23 @@ impl PunktfunkHidOutput {
|
||||
out.effect[..n].copy_from_slice(&effect[..n]);
|
||||
out.effect_len = n as u8;
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
side,
|
||||
amplitude,
|
||||
period,
|
||||
count,
|
||||
} => {
|
||||
// No new struct (PunktfunkHidOutput has no size guard): pack into the existing
|
||||
// `which` (side) + `effect[0..6]` (amplitude/period/count LE), `effect_len = 6`.
|
||||
out.kind = PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC;
|
||||
out.pad = *pad;
|
||||
out.which = *side;
|
||||
out.effect[0..2].copy_from_slice(&litude.to_le_bytes());
|
||||
out.effect[2..4].copy_from_slice(&period.to_le_bytes());
|
||||
out.effect[4..6].copy_from_slice(&count.to_le_bytes());
|
||||
out.effect_len = 6;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -618,6 +639,11 @@ impl PunktfunkHdrMeta {
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
||||
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||
pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
|
||||
/// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the
|
||||
/// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes
|
||||
/// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` +
|
||||
/// `punktfunk_connection_send_rich_input2` (added with client capture).
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD_EX: u8 = 3;
|
||||
|
||||
/// One rich client→host input for the host's virtual DualSense
|
||||
/// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind`
|
||||
@@ -666,6 +692,77 @@ impl PunktfunkRichInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward-compatible superset of [`PunktfunkRichInput`] that can also express the rich Steam
|
||||
/// surfaces: a *second* trackpad (`surface`), a distinct `click` vs touch, signed coordinates, and
|
||||
/// pressure. Sent via [`punktfunk_connection_send_rich_input2`] — the only way a C client can emit a
|
||||
/// `TouchpadEx`. The caller MUST set `struct_size = sizeof(PunktfunkRichInputEx)` (the ABI-skew
|
||||
/// guard, like [`PunktfunkConfig`]); the legacy [`PunktfunkRichInput`] +
|
||||
/// [`punktfunk_connection_send_rich_input`] stay byte-for-byte for existing callers.
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PunktfunkRichInputEx {
|
||||
/// MUST equal `sizeof(PunktfunkRichInputEx)`.
|
||||
pub struct_size: u32,
|
||||
/// One of `PUNKTFUNK_RICH_*` (`TOUCHPAD` / `MOTION` / `TOUCHPAD_EX`).
|
||||
pub kind: u8,
|
||||
/// Gamepad index.
|
||||
pub pad: u8,
|
||||
/// Touchpad/TouchpadEx: contact id.
|
||||
pub finger: u8,
|
||||
/// Touchpad/TouchpadEx: 1 = finger down / touching, 0 = lifted.
|
||||
pub active: u8,
|
||||
/// TouchpadEx: which surface — 0 = single/DualSense, 1 = Steam left pad, 2 = Steam right pad.
|
||||
pub surface: u8,
|
||||
/// TouchpadEx: 1 = the pad is physically clicked (depressed), distinct from a touch contact.
|
||||
pub click: u8,
|
||||
/// Reserved for alignment; set to 0.
|
||||
pub _reserved: [u8; 2],
|
||||
/// TouchpadEx: x coordinate — **signed**, centred at 0 (the real Steam report convention). For a
|
||||
/// legacy `TOUCHPAD` kind sent through this struct, store the unsigned `0..=65535` value's bits.
|
||||
pub x: i16,
|
||||
/// TouchpadEx: y coordinate — signed, centred at 0.
|
||||
pub y: i16,
|
||||
/// TouchpadEx: contact pressure (`0` if the surface has no force sensor).
|
||||
pub pressure: u16,
|
||||
/// Motion: gyro (pitch, yaw, roll), raw signed-16.
|
||||
pub gyro: [i16; 3],
|
||||
/// Motion: accelerometer (x, y, z), raw signed-16.
|
||||
pub accel: [i16; 3],
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
impl PunktfunkRichInputEx {
|
||||
fn to_rich(self) -> Option<crate::quic::RichInput> {
|
||||
use crate::quic::RichInput;
|
||||
match self.kind {
|
||||
PUNKTFUNK_RICH_TOUCHPAD_EX => Some(RichInput::TouchpadEx {
|
||||
pad: self.pad,
|
||||
surface: self.surface,
|
||||
finger: self.finger,
|
||||
touch: self.active != 0,
|
||||
click: self.click != 0,
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
pressure: self.pressure,
|
||||
}),
|
||||
PUNKTFUNK_RICH_MOTION => Some(RichInput::Motion {
|
||||
pad: self.pad,
|
||||
gyro: self.gyro,
|
||||
accel: self.accel,
|
||||
}),
|
||||
PUNKTFUNK_RICH_TOUCHPAD => Some(RichInput::Touchpad {
|
||||
pad: self.pad,
|
||||
finger: self.finger,
|
||||
active: self.active != 0,
|
||||
x: self.x as u16,
|
||||
y: self.y as u16,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
|
||||
#[cfg(feature = "quic")]
|
||||
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
|
||||
@@ -714,6 +811,22 @@ pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||
/// hosts); otherwise the host falls back to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
|
||||
/// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
||||
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
|
||||
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
|
||||
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||
|
||||
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
||||
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
||||
/// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE1: u32 = 0x0001_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE2: u32 = 0x0002_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE3: u32 = 0x0004_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE4: u32 = 0x0008_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_MISC1: u32 = 0x0020_0000;
|
||||
|
||||
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||
@@ -742,11 +855,28 @@ const _: () = {
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
const _: () = {
|
||||
use crate::config::GamepadPref;
|
||||
use crate::input::gamepad as g;
|
||||
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
|
||||
// Extended button bits mirror the wire `input::gamepad` constants.
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE3 == g::BTN_PADDLE3);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE4 == g::BTN_PADDLE4);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_MISC1 == g::BTN_MISC1);
|
||||
};
|
||||
|
||||
// The additive M3 kinds (TouchpadEx / TrackpadHaptic) must never grow the legacy ABI structs —
|
||||
// they have no `struct_size` guard, so a layout change would corrupt old-built callers' buffers.
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
assert!(core::mem::size_of::<PunktfunkRichInput>() == 20);
|
||||
assert!(core::mem::size_of::<PunktfunkHidOutput>() == 19);
|
||||
};
|
||||
|
||||
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
||||
@@ -1727,6 +1857,43 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a rich client→host input via the forward-compatible [`PunktfunkRichInputEx`] — the only way
|
||||
/// a C client can emit a `TouchpadEx` (a second trackpad / signed coords / pressure). Set
|
||||
/// `rich->struct_size = sizeof(PunktfunkRichInputEx)`; a smaller (older-layout) value is rejected.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `rich` is null or points to at least its declared
|
||||
/// `struct_size` bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
|
||||
c: *mut PunktfunkConnection,
|
||||
rich: *const PunktfunkRichInputEx,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if rich.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
// Read only the 4-byte size prefix first to bound the subsequent full read (the
|
||||
// `PunktfunkConfig` ABI-skew precedent).
|
||||
let declared = unsafe { std::ptr::addr_of!((*rich).struct_size).read_unaligned() } as usize;
|
||||
if declared < std::mem::size_of::<PunktfunkRichInputEx>() {
|
||||
return PunktfunkStatus::InvalidArg;
|
||||
}
|
||||
match unsafe { *rich }.to_rich() {
|
||||
Some(r) => match c.inner.send_rich_input(r) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(e) => e.status(),
|
||||
},
|
||||
None => PunktfunkStatus::InvalidArg,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The currently active session mode — the Welcome's, until an accepted
|
||||
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
||||
///
|
||||
|
||||
@@ -137,8 +137,9 @@ impl CompositorPref {
|
||||
/// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is
|
||||
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
|
||||
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
|
||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`), appended to
|
||||
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`), appended to `Hello`/`Welcome` — older peers simply
|
||||
/// omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GamepadPref {
|
||||
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
||||
@@ -155,10 +156,19 @@ pub enum GamepadPref {
|
||||
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
|
||||
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
|
||||
DualShock4,
|
||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — dual trackpads, gyro,
|
||||
/// two grip paddles, trackpad-only haptics. Needs Linux UHID. *(Reserved; its backend is not yet
|
||||
/// built — currently folds to `Xbox360`; the Deck identity below is the implemented one.)*
|
||||
SteamController,
|
||||
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl.
|
||||
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input
|
||||
/// with native glyphs when Steam runs on the host. Needs Linux UHID.
|
||||
SteamDeck,
|
||||
}
|
||||
|
||||
impl GamepadPref {
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`.
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`.
|
||||
pub const fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
GamepadPref::Auto => 0,
|
||||
@@ -166,6 +176,8 @@ impl GamepadPref {
|
||||
GamepadPref::DualSense => 2,
|
||||
GamepadPref::XboxOne => 3,
|
||||
GamepadPref::DualShock4 => 4,
|
||||
GamepadPref::SteamController => 5,
|
||||
GamepadPref::SteamDeck => 6,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +189,8 @@ impl GamepadPref {
|
||||
2 => GamepadPref::DualSense,
|
||||
3 => GamepadPref::XboxOne,
|
||||
4 => GamepadPref::DualShock4,
|
||||
5 => GamepadPref::SteamController,
|
||||
6 => GamepadPref::SteamDeck,
|
||||
_ => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -192,12 +206,14 @@ impl GamepadPref {
|
||||
GamepadPref::XboxOne
|
||||
}
|
||||
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
|
||||
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
|
||||
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
||||
/// `"dualshock4"`).
|
||||
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GamepadPref::Auto => "auto",
|
||||
@@ -205,6 +221,8 @@ impl GamepadPref {
|
||||
GamepadPref::DualSense => "dualsense",
|
||||
GamepadPref::XboxOne => "xboxone",
|
||||
GamepadPref::DualShock4 => "dualshock4",
|
||||
GamepadPref::SteamController => "steamcontroller",
|
||||
GamepadPref::SteamDeck => "steamdeck",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,4 +399,27 @@ mod tests {
|
||||
c.fec.fec_percent = 15; // 250 + ceil(250*15/100)=288 > 255
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_pref_steam_roundtrip() {
|
||||
use GamepadPref::*;
|
||||
// Wire-byte round-trip for the Steam additions; an unknown byte still degrades to Auto.
|
||||
for (p, b) in [(SteamController, 5u8), (SteamDeck, 6)] {
|
||||
assert_eq!(p.to_u8(), b);
|
||||
assert_eq!(GamepadPref::from_u8(b), p);
|
||||
}
|
||||
assert_eq!(GamepadPref::from_u8(99), Auto);
|
||||
// Name parsing + canonical-name round-trip.
|
||||
assert_eq!(GamepadPref::from_name("steamdeck"), Some(SteamDeck));
|
||||
assert_eq!(GamepadPref::from_name("deck"), Some(SteamDeck));
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("steamcontroller"),
|
||||
Some(SteamController)
|
||||
);
|
||||
assert_eq!(SteamDeck.as_str(), "steamdeck");
|
||||
assert_eq!(
|
||||
GamepadPref::from_name(SteamController.as_str()),
|
||||
Some(SteamController)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,24 @@ pub mod gamepad {
|
||||
pub const BTN_B: u32 = 0x2000;
|
||||
pub const BTN_X: u32 = 0x4000;
|
||||
pub const BTN_Y: u32 = 0x8000;
|
||||
// Extended buttons in Moonlight's `buttonFlags2 << 16` namespace (see `gamestream/gamepad.rs`),
|
||||
// so the GameStream paddle path and the native path share one host injector map. The four Steam
|
||||
// Deck back grips (L4/L5/R4/R5) reuse the four GameStream/Xbox-Elite paddle slots — a semantic
|
||||
// 1:1 for binding (the device identity carries the glyph distinction).
|
||||
/// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
|
||||
pub const BTN_PADDLE1: u32 = 0x0001_0000;
|
||||
/// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
|
||||
pub const BTN_PADDLE2: u32 = 0x0002_0000;
|
||||
/// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
|
||||
pub const BTN_PADDLE3: u32 = 0x0004_0000;
|
||||
/// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
|
||||
pub const BTN_PADDLE4: u32 = 0x0008_0000;
|
||||
/// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||
/// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
||||
/// the same bit. Only the DualSense backend renders it; the xpad has no such button.
|
||||
pub const BTN_TOUCHPAD: u32 = 0x10_0000;
|
||||
/// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
|
||||
pub const BTN_MISC1: u32 = 0x0020_0000;
|
||||
|
||||
/// Axis ids for `InputKind::GamepadAxis`.
|
||||
pub const AXIS_LS_X: u32 = 0;
|
||||
|
||||
@@ -1218,6 +1218,7 @@ pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||
|
||||
const RICH_TOUCHPAD: u8 = 0x01;
|
||||
const RICH_MOTION: u8 = 0x02;
|
||||
const RICH_TOUCHPAD_EX: u8 = 0x03;
|
||||
|
||||
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
|
||||
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
|
||||
@@ -1241,6 +1242,22 @@ pub enum RichInput {
|
||||
gyro: [i16; 3],
|
||||
accel: [i16; 3],
|
||||
},
|
||||
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
|
||||
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
|
||||
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
|
||||
/// Coordinates are **signed** (centred at 0), matching the real Steam report; `pressure` is `0`
|
||||
/// for a surface with no force sensor. New clients send this for every touch surface; the host
|
||||
/// decodes both `Touchpad` (`0x01`) and `TouchpadEx` (`0x03`) indefinitely.
|
||||
TouchpadEx {
|
||||
pad: u8,
|
||||
surface: u8,
|
||||
finger: u8,
|
||||
touch: bool,
|
||||
click: bool,
|
||||
x: i16,
|
||||
y: i16,
|
||||
pressure: u16,
|
||||
},
|
||||
}
|
||||
|
||||
impl RichInput {
|
||||
@@ -1264,6 +1281,22 @@ impl RichInput {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
pad,
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
click,
|
||||
x,
|
||||
y,
|
||||
pressure,
|
||||
} => {
|
||||
let state = (touch as u8) | ((click as u8) << 1);
|
||||
out.extend_from_slice(&[RICH_TOUCHPAD_EX, pad, surface, finger, state]);
|
||||
out.extend_from_slice(&x.to_le_bytes());
|
||||
out.extend_from_slice(&y.to_le_bytes());
|
||||
out.extend_from_slice(&pressure.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1288,6 +1321,16 @@ impl RichInput {
|
||||
accel: [i16at(9), i16at(11), i16at(13)],
|
||||
})
|
||||
}
|
||||
RICH_TOUCHPAD_EX if b.len() >= 12 => Some(RichInput::TouchpadEx {
|
||||
pad: b[2],
|
||||
surface: b[3],
|
||||
finger: b[4],
|
||||
touch: b[5] & 0x01 != 0,
|
||||
click: b[5] & 0x02 != 0,
|
||||
x: i16::from_le_bytes([b[6], b[7]]),
|
||||
y: i16::from_le_bytes([b[8], b[9]]),
|
||||
pressure: u16::from_le_bytes([b[10], b[11]]),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1296,6 +1339,7 @@ impl RichInput {
|
||||
const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||
|
||||
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
|
||||
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
|
||||
@@ -1309,6 +1353,16 @@ pub enum HidOutput {
|
||||
/// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense
|
||||
/// trigger parameter block (mode + params) for the client to replay on a real controller.
|
||||
Trigger { pad: u8, which: u8, effect: Vec<u8> },
|
||||
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
|
||||
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
|
||||
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
|
||||
TrackpadHaptic {
|
||||
pad: u8,
|
||||
side: u8,
|
||||
amplitude: u16,
|
||||
period: u16,
|
||||
count: u16,
|
||||
},
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -1325,6 +1379,18 @@ impl HidOutput {
|
||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||
out.extend_from_slice(effect);
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
side,
|
||||
amplitude,
|
||||
period,
|
||||
count,
|
||||
} => {
|
||||
out.extend_from_slice(&[HIDOUT_TRACKPAD_HAPTIC, *pad, *side]);
|
||||
out.extend_from_slice(&litude.to_le_bytes());
|
||||
out.extend_from_slice(&period.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1349,6 +1415,13 @@ impl HidOutput {
|
||||
which: b[3],
|
||||
effect: b[4..].to_vec(),
|
||||
}),
|
||||
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
|
||||
pad: b[2],
|
||||
side: b[3],
|
||||
amplitude: u16::from_le_bytes([b[4], b[5]]),
|
||||
period: u16::from_le_bytes([b[6], b[7]]),
|
||||
count: u16::from_le_bytes([b[8], b[9]]),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2486,6 +2559,16 @@ mod tests {
|
||||
gyro: [-100, 200, -300],
|
||||
accel: [16384, -8192, 1],
|
||||
},
|
||||
RichInput::TouchpadEx {
|
||||
pad: 2,
|
||||
surface: 1,
|
||||
finger: 1,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: -12345,
|
||||
y: 30000,
|
||||
pressure: 4000,
|
||||
},
|
||||
] {
|
||||
let d = ev.encode();
|
||||
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||
@@ -2494,7 +2577,8 @@ mod tests {
|
||||
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
||||
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none());
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); // short
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 0]).is_none());
|
||||
// short
|
||||
}
|
||||
|
||||
@@ -2516,6 +2600,13 @@ mod tests {
|
||||
which: 1,
|
||||
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
||||
},
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad: 0,
|
||||
side: 1,
|
||||
amplitude: 0x1234,
|
||||
period: 0x5678,
|
||||
count: 9,
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
|
||||
@@ -89,6 +89,9 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
||||
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
|
||||
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
|
||||
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
|
||||
# `wayland-backend` is referenced by the generated interface tables.
|
||||
@@ -119,6 +122,10 @@ ash = "0.38"
|
||||
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
|
||||
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
|
||||
libloading = "0.8"
|
||||
# Vendored + trimmed `usbip` server core (no libusb) — presents a virtual Steam Deck over USB/IP
|
||||
# so the local `vhci_hcd` attaches it: the shippable, Secure-Boot-clean, Steam-Input-promotable
|
||||
# virtual-Deck transport on non-SteamOS hosts (`inject/linux/steam_usbip.rs`). See the crate's NOTICE.
|
||||
usbip-sim = { path = "vendor/usbip-sim" }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
|
||||
@@ -175,6 +182,9 @@ windows = { version = "0.62", features = [
|
||||
# Windows service supervisor (src/service.rs): a kill-on-close job object so a service crash never
|
||||
# orphans the SYSTEM host it launched into the interactive session.
|
||||
"Win32_System_JobObjects",
|
||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||
"Win32_System_Com",
|
||||
] }
|
||||
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
|
||||
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
||||
audio_control::ensure_wired_once();
|
||||
wasapi_cap::WasapiLoopbackCapturer::open(channels)
|
||||
.map(|c| Box::new(c) as Box<dyn AudioCapturer>)
|
||||
}
|
||||
@@ -77,6 +78,7 @@ pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
audio_control::ensure_wired_once();
|
||||
wasapi_mic::WasapiVirtualMic::open(channels).map(|m| Box::new(m) as Box<dyn VirtualMic>)
|
||||
}
|
||||
|
||||
@@ -85,6 +87,9 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/audio_control.rs"]
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Windows audio device auto-wiring — production mic + desktop-audio passthrough with zero manual
|
||||
//! setup.
|
||||
//!
|
||||
//! A headless host has no real audio output, so BOTH the desktop-audio loopback ([`super::wasapi_cap`])
|
||||
//! and the virtual mic ([`super::wasapi_mic`]) must run on VIRTUAL audio cables — and on DIFFERENT
|
||||
//! ones, or the loopback re-captures the injected mic (an infinite echo). The installer bundles
|
||||
//! VB-Audio Virtual Cable (the mic target: its "CABLE Input" render endpoint → "CABLE Output" capture)
|
||||
//! and the host auto-installs the Steam Streaming pair (a loopback-capable render). This module wires
|
||||
//! them up at startup so no manual Sound-settings fiddling is ever needed:
|
||||
//!
|
||||
//! * default **PLAYBACK** → a loopback-capable render that is NOT the mic cable (a real output device
|
||||
//! if one exists, else the Steam Streaming Microphone; **never** the Steam Streaming Speakers, whose
|
||||
//! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] loopback-captures
|
||||
//! for desktop audio.
|
||||
//! * default **RECORDING** → the virtual mic's capture endpoint (VB-Cable "CABLE Output") so host apps
|
||||
//! record the client's mic by default.
|
||||
//!
|
||||
//! [`super::wasapi_mic::find_device`] then resolves the mic INJECT target to "CABLE Input" — a render
|
||||
//! candidate that is NOT the default playback — guaranteeing loopback ≠ mic, so there is no echo.
|
||||
//!
|
||||
//! Setting a default endpoint uses the undocumented `IPolicyConfig` COM interface (the only way to set
|
||||
//! a default device programmatically — neither the `windows` nor `wasapi` crate exposes it; it is the
|
||||
//! same call `mmsys.cpl` makes). Opt out with `PUNKTFUNK_KEEP_DEFAULT` to leave the user's chosen
|
||||
//! defaults untouched.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::Once;
|
||||
use wasapi::Direction;
|
||||
|
||||
/// Run the audio device auto-wiring exactly once per process, before the first capturer/mic opens.
|
||||
/// Blocks until done so the default playback is set before the loopback captures it. Best-effort:
|
||||
/// every failure is logged, never fatal (the host then falls back to whatever the current defaults
|
||||
/// are — exactly the pre-wiring behaviour).
|
||||
pub(crate) fn ensure_wired_once() {
|
||||
static WIRED: Once = Once::new();
|
||||
WIRED.call_once(|| {
|
||||
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
|
||||
tracing::info!("PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched");
|
||||
return;
|
||||
}
|
||||
// Run on a dedicated COM-MTA thread so we never collide with the caller's apartment mode
|
||||
// (the capture/mic threads each initialize their own COM separately).
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("pf-audio-wiring".into())
|
||||
.spawn(|| {
|
||||
if wasapi::initialize_mta().ok().is_err() {
|
||||
tracing::warn!("audio wiring: COM init (MTA) failed — skipping");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = ensure_audio_wiring() {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"audio auto-wiring failed — mic/desktop audio may need manual device defaults");
|
||||
}
|
||||
});
|
||||
if let Ok(h) = handle {
|
||||
let _ = h.join();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `(friendly_name, endpoint_id)` for every ACTIVE endpoint in direction `dir`.
|
||||
fn list_endpoints(dir: Direction) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(en) = wasapi::DeviceEnumerator::new() else {
|
||||
return out;
|
||||
};
|
||||
let Ok(coll) = en.get_device_collection(&dir) else {
|
||||
return out;
|
||||
};
|
||||
let Ok(n) = coll.get_nbr_devices() else {
|
||||
return out;
|
||||
};
|
||||
for i in 0..n {
|
||||
if let Ok(dev) = coll.get_device_at_index(i) {
|
||||
let id = dev.get_id().unwrap_or_default();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push((dev.get_friendlyname().unwrap_or_default(), id));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pick the loopback + mic-capture devices and set them as the default playback/recording.
|
||||
fn ensure_audio_wiring() -> Result<()> {
|
||||
let renders = list_endpoints(Direction::Render);
|
||||
let captures = list_endpoints(Direction::Capture);
|
||||
if renders.is_empty() {
|
||||
bail!("no active render endpoints to wire");
|
||||
}
|
||||
|
||||
// A render is unusable as the desktop-audio loopback if it is a VB-Cable endpoint (reserved for
|
||||
// the mic inject) or the Steam Streaming Speakers (its loopback is silent — validated live).
|
||||
let excluded_loopback =
|
||||
|ln: &str| ln.contains("cable") || ln.contains("steam streaming speakers");
|
||||
// "virtual-ish" = a known virtual cable; a render WITHOUT these markers is a real output device,
|
||||
// the best loopback source (apps render there and the operator can also hear it).
|
||||
let virtualish = |ln: &str| {
|
||||
ln.contains("virtual")
|
||||
|| ln.contains("cable")
|
||||
|| ln.contains("steam streaming")
|
||||
|| ln.contains("voicemeeter")
|
||||
};
|
||||
let loopback = renders
|
||||
.iter()
|
||||
.find(|(n, _)| {
|
||||
let ln = n.to_lowercase();
|
||||
!excluded_loopback(&ln) && !virtualish(&ln)
|
||||
})
|
||||
.or_else(|| {
|
||||
renders
|
||||
.iter()
|
||||
.find(|(n, _)| n.to_lowercase().contains("steam streaming microphone"))
|
||||
})
|
||||
.or_else(|| {
|
||||
renders
|
||||
.iter()
|
||||
.find(|(n, _)| !excluded_loopback(&n.to_lowercase()))
|
||||
});
|
||||
|
||||
// The virtual mic's CAPTURE endpoint host apps record from — VB-Cable "CABLE Output" preferred.
|
||||
let mic_capture = captures
|
||||
.iter()
|
||||
.find(|(n, _)| n.to_lowercase().contains("cable output"))
|
||||
.or_else(|| {
|
||||
captures
|
||||
.iter()
|
||||
.find(|(n, _)| n.to_lowercase().contains("steam streaming microphone"))
|
||||
})
|
||||
.or_else(|| {
|
||||
captures.iter().find(|(n, _)| {
|
||||
let ln = n.to_lowercase();
|
||||
ln.contains("voicemeeter") || ln.contains("virtual")
|
||||
})
|
||||
});
|
||||
|
||||
match loopback {
|
||||
Some((name, id)) => match set_default_endpoint(id) {
|
||||
Ok(()) => tracing::info!(device = %name,
|
||||
"audio wiring: default playback = desktop-audio loopback source"),
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default playback device"),
|
||||
},
|
||||
None => {
|
||||
tracing::warn!("audio wiring: no usable desktop-audio loopback render endpoint found")
|
||||
}
|
||||
}
|
||||
if let Some((name, id)) = mic_capture {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)"),
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||
|
||||
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` is called; the 10 methods between `Release`
|
||||
/// and it (`GetMixFormat` … `SetPropertyValue`) are placeholders so the slot offset is correct.
|
||||
#[repr(C)]
|
||||
struct IPolicyConfigVtbl {
|
||||
query_interface: unsafe extern "system" fn(
|
||||
*mut c_void,
|
||||
*const windows::core::GUID,
|
||||
*mut *mut c_void,
|
||||
) -> windows::core::HRESULT,
|
||||
add_ref: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
release: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
_reserved: [*const c_void; 10],
|
||||
set_default_endpoint: unsafe extern "system" fn(
|
||||
*mut c_void,
|
||||
windows::core::PCWSTR,
|
||||
u32,
|
||||
) -> windows::core::HRESULT,
|
||||
// SetEndpointVisibility follows — unused.
|
||||
}
|
||||
|
||||
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
||||
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
|
||||
/// fails.
|
||||
fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
||||
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
||||
|
||||
// PolicyConfigClient coclass + IPolicyConfig (Win7+) IID.
|
||||
const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9);
|
||||
const IID_IPOLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8);
|
||||
|
||||
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// SAFETY: CoCreateInstance with a valid CLSID returns an owned, refcounted IUnknown. We QI it for
|
||||
// IPolicyConfig; on success (HRESULT ok + non-null pointer) we invoke its SetDefaultEndpoint slot
|
||||
// through the documented vtable layout (3 IUnknown + 10 placeholder methods precede it) with a
|
||||
// NUL-terminated UTF-16 id and an in-range ERole (0..=2), then Release the QI'd pointer. Every
|
||||
// pointer is checked non-null before deref; `unk` is Released by its Drop on scope exit.
|
||||
unsafe {
|
||||
let unk: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL)
|
||||
.map_err(|e| anyhow!("CoCreateInstance(PolicyConfig): {e}"))?;
|
||||
let mut raw: *mut c_void = std::ptr::null_mut();
|
||||
unk.query(&IID_IPOLICY_CONFIG, &mut raw)
|
||||
.ok()
|
||||
.map_err(|e| anyhow!("QueryInterface(IPolicyConfig): {e}"))?;
|
||||
if raw.is_null() {
|
||||
bail!("IPolicyConfig QueryInterface returned null");
|
||||
}
|
||||
let vtbl = *(raw as *const *const IPolicyConfigVtbl);
|
||||
let mut result = Ok(());
|
||||
for role in 0u32..=2 {
|
||||
let hr = ((*vtbl).set_default_endpoint)(raw, PCWSTR(wide.as_ptr()), role);
|
||||
if hr.is_err() {
|
||||
result = hr
|
||||
.ok()
|
||||
.map_err(|e| anyhow!("SetDefaultEndpoint(role {role}): {e}"));
|
||||
}
|
||||
}
|
||||
((*vtbl).release)(raw);
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
//! **capture** endpoint then surfaces as a microphone that host apps can record from.
|
||||
//!
|
||||
//! Target device, by friendly-name substring (first match wins; override with `PUNKTFUNK_MIC_DEVICE`):
|
||||
//! "Steam Streaming Microphone" (ships with Steam Remote Play — exactly this purpose), VB-Audio
|
||||
//! "CABLE Input", VoiceMeeter, or anything with "virtual" in the name. If none is present we
|
||||
//! auto-install the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we
|
||||
//! return an error with install guidance and the host runs without mic passthrough.
|
||||
//! VB-Audio "CABLE Input" (bundled by the installer — the preferred, dedicated mic target), the
|
||||
//! "Steam Streaming Microphone", VoiceMeeter, or anything with "virtual" in the name.
|
||||
//! [`super::audio_control`] sets the default playback to a DIFFERENT loopback-capable device so the
|
||||
//! chosen mic is never the endpoint the loopback captures. If no candidate is present we auto-install
|
||||
//! the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we return an error
|
||||
//! with install guidance and the host runs without mic passthrough.
|
||||
//!
|
||||
//! **Anti-echo guard (the whole point of this being non-trivial).** The desktop-audio plane
|
||||
//! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback
|
||||
@@ -45,8 +47,8 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 80 / 1000) * BLOCK_ALIGN;
|
||||
/// Render-endpoint friendly-name substrings (lowercased) we can write into so the device's capture
|
||||
/// endpoint becomes a host mic. Ordered by preference.
|
||||
const CANDIDATES: &[&str] = &[
|
||||
"cable input", // VB-Audio Virtual Cable — bundled by the installer; the preferred dedicated mic target
|
||||
"steam streaming microphone",
|
||||
"cable input",
|
||||
"voicemeeter input",
|
||||
"voicemeeter aux input",
|
||||
"virtual",
|
||||
|
||||
@@ -59,7 +59,7 @@ 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).
|
||||
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source).
|
||||
/// `false` = 8-bit SDR.
|
||||
pub hdr: bool,
|
||||
/// Full-chroma 4:4:4 session: the capturer must keep full chroma — deliver packed **RGB**
|
||||
@@ -380,23 +380,12 @@ pub fn capture_virtual_output(
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_NO_WGC=1` forces the pure single-process DDA (Desktop Duplication) path everywhere: it
|
||||
/// skips WGC in [`capture_virtual_output`] AND bypasses the two-process secure-desktop relay (so even a
|
||||
/// SYSTEM host captures in-process via DDA, the way Apollo does — one capturer for the normal AND the
|
||||
/// secure desktop). For bringing DDA up to parity / validating it on its own; all the WGC code stays
|
||||
/// compiled and comes back the moment the flag is unset.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn wgc_disabled() -> bool {
|
||||
crate::config::config().no_wgc
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
capture: crate::session_plan::CaptureBackend,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
use crate::session_plan::CaptureBackend;
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
||||
@@ -404,97 +393,36 @@ pub fn capture_virtual_output(
|
||||
})?;
|
||||
let pref = vout.preferred_mode;
|
||||
let keep = vout.keepalive;
|
||||
// Full-chroma 4:4:4 needs a full-chroma RGB source. The IDD-push and WGC paths emit subsampled
|
||||
// NV12/P010 by default, which can't reconstruct 4:4:4; route a 4:4:4 session to DDA, which delivers
|
||||
// RGB (Bgra) when its `chroma_444` flag is set. (IDD-push/WGC 4:4:4 capture is a follow-up.)
|
||||
if want.chroma_444 && capture != CaptureBackend::Dda {
|
||||
tracing::info!("4:4:4 session — using DDA capture (RGB source) instead of {capture:?}");
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
// 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,
|
||||
want.chroma_444,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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),
|
||||
// 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
|
||||
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
|
||||
// backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
|
||||
if capture == CaptureBackend::Dda {
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
|
||||
// intermittently HANGS on the headless SudoVDA (IddCx) display — a blocking call we can't error out
|
||||
// of in place. So run WGC open on a dedicated thread and bound it: if it doesn't finish in time
|
||||
// (hang) or errors, fall back to the reliable DDA path so the session is NEVER left black. WGC,
|
||||
// when it opens, captures the composed desktop (overlay/MPO-correct HDR — fixes frozen animations);
|
||||
// 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
|
||||
// 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 {
|
||||
let _ = windows::Win32::System::WinRT::RoInitialize(
|
||||
windows::Win32::System::WinRT::RO_INIT_MULTITHREADED,
|
||||
);
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let t = target.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("wgc-open".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(wgc::WgcCapturer::open(t, pref));
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||
Ok(Ok(mut c)) => {
|
||||
c.attach_keepalive(keep);
|
||||
Ok(Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
}
|
||||
// IDD direct-push is the sole Windows capture path: consume frames straight from the pf-vdisplay
|
||||
// driver's shared ring (in-process, Session 0 — it captures the secure desktop too; no Desktop
|
||||
// Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's
|
||||
// swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display
|
||||
// is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and
|
||||
// selects the per-frame conversion (FP16 → P010 vs BGRA → NV12). `IddPushCapturer` takes the
|
||||
// keepalive (it owns the virtual display). There is NO fallback (DDA + the WGC relay were removed):
|
||||
// if it can't open or the driver doesn't attach, the session fails cleanly and the client reconnects.
|
||||
idd_push::IddPushCapturer::open(target, pref, want.hdr, keep)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
|
||||
}
|
||||
|
||||
/// Whether the active capturer can deliver a full-chroma (RGB) source for a 4:4:4 HEVC encode. The
|
||||
/// negotiator gates 4:4:4 on this so the host honestly downgrades to 4:2:0 when the capturer can only
|
||||
/// produce subsampled frames. Linux (the portal capturer feeding CPU RGB → `yuv444p`) can; the Windows
|
||||
/// IDD-push path delivers subsampled NV12/P010 today, so full-chroma capture there is a follow-up.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn capturer_supports_444() -> bool {
|
||||
true
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn capturer_supports_444() -> bool {
|
||||
// IDD-push 4:4:4 (full-chroma RGB from the FP16 ring) is the next step; until then the sole Windows
|
||||
// capturer delivers subsampled NV12/P010 only, so the host honestly negotiates 4:2:0.
|
||||
false
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub(crate) fn capturer_supports_444() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
@@ -506,14 +434,9 @@ pub fn capture_virtual_output(
|
||||
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")]
|
||||
#[path = "capture/windows/composed_flip.rs"]
|
||||
pub mod composed_flip;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/desktop_watch.rs"]
|
||||
pub mod desktop_watch;
|
||||
// Goal-1 stage 6: the Windows backend lives under `capture/windows/`, the Linux one under `capture/linux/`
|
||||
// (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged). Windows capture
|
||||
// is IDD direct-push only — DXGI Desktop Duplication (DDA) and the WGC two-process relay were removed.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/dxgi.rs"]
|
||||
pub mod dxgi;
|
||||
@@ -522,9 +445,3 @@ pub mod dxgi;
|
||||
pub mod idd_push;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/wgc.rs"]
|
||||
pub mod wgc;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/wgc_relay.rs"]
|
||||
pub mod wgc_relay;
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
//! Force-composed-flip overlay (Windows) — make the secure (Winlogon: UAC / lock / login) desktop
|
||||
//! capturable by Desktop Duplication.
|
||||
//!
|
||||
//! The secure desktop's dialog/wallpaper presents via **fullscreen independent-flip / MPO**: it scans
|
||||
//! out directly, bypassing DWM composition, so `IDXGIOutputDuplication::AcquireNextFrame` returns
|
||||
//! `DXGI_ERROR_ACCESS_LOST` (born-lost) — there is no composed frame to hand out (the client sees
|
||||
//! black). Independent-flip requires the presenting app to own the ENTIRE output: putting ANY other
|
||||
//! visible window on that output disqualifies it, forcing DWM to **composite**, which DDA can then
|
||||
//! capture. So we keep a tiny, click-through, near-invisible **topmost layered window** alive on the
|
||||
//! *current input desktop* (which is Winlogon while the secure desktop is up). On a desktop switch the
|
||||
//! window is orphaned, so a dedicated thread tracks the input desktop and recreates it there.
|
||||
//!
|
||||
//! This is the non-input alternative to "tap a key to wake the lock screen": it needs no SendInput and
|
||||
//! no system-wide registry change (it does NOT disable MPO globally — it only nudges OUR output to
|
||||
//! composed while a session is live). Effectiveness can be build/driver-dependent; gated by
|
||||
//! `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::Arc;
|
||||
use windows::core::w;
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::System::StationsAndDesktops::{
|
||||
CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, SetThreadDesktop,
|
||||
DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, UOI_NAME,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, PeekMessageW, RegisterClassW,
|
||||
SetLayeredWindowAttributes, SetWindowPos, ShowWindow, TranslateMessage, HWND_TOPMOST,
|
||||
LWA_ALPHA, MSG, PM_REMOVE, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SW_SHOWNOACTIVATE,
|
||||
WNDCLASSW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT,
|
||||
WS_POPUP,
|
||||
};
|
||||
|
||||
/// A running force-composed-flip overlay. Drop signals the thread to tear down its window + exit.
|
||||
pub struct ForceComposedFlip {
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ForceComposedFlip {
|
||||
/// Start the overlay (no-op + `None` if disabled via `PUNKTFUNK_FORCE_COMPOSED=0`).
|
||||
pub fn start() -> Option<Self> {
|
||||
if std::env::var("PUNKTFUNK_FORCE_COMPOSED").as_deref() == Ok("0") {
|
||||
tracing::info!("force-composed-flip overlay disabled (PUNKTFUNK_FORCE_COMPOSED=0)");
|
||||
return None;
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let st = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.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) })
|
||||
.ok()?;
|
||||
tracing::info!("force-composed-flip overlay started (Winlogon-aware)");
|
||||
Some(ForceComposedFlip { stop })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ForceComposedFlip {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
/// Read the current input-desktop name (e.g. "Default" / "Winlogon"); `None` if it can't be read.
|
||||
unsafe fn input_desktop_name() -> Option<String> {
|
||||
let desk = OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
false,
|
||||
DESKTOP_ACCESS_FLAGS(0x0001),
|
||||
)
|
||||
.ok()?;
|
||||
let mut buf = [0u16; 64];
|
||||
let mut needed = 0u32;
|
||||
let ok = GetUserObjectInformationW(
|
||||
windows::Win32::Foundation::HANDLE(desk.0),
|
||||
UOI_NAME,
|
||||
Some(buf.as_mut_ptr() as *mut _),
|
||||
(buf.len() * 2) as u32,
|
||||
Some(&mut needed),
|
||||
)
|
||||
.is_ok();
|
||||
let _ = CloseDesktop(desk);
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
String::from_utf16_lossy(&buf)
|
||||
.trim_end_matches('\u{0}')
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create the tiny topmost layered click-through window on the CURRENT thread's desktop. Caller must
|
||||
/// have `SetThreadDesktop`'d to the target input desktop first.
|
||||
unsafe fn make_overlay() -> Option<HWND> {
|
||||
let hinst = GetModuleHandleW(None).ok()?;
|
||||
let class = w!("PunktfunkComposedFlip");
|
||||
// RegisterClassW is idempotent-ish: a second register for the same name fails harmlessly; we
|
||||
// ignore the result and rely on the class existing. (One process, so it registers once.)
|
||||
let wc = WNDCLASSW {
|
||||
lpfnWndProc: Some(wndproc),
|
||||
hInstance: hinst.into(),
|
||||
lpszClassName: class,
|
||||
..Default::default()
|
||||
};
|
||||
let atom = RegisterClassW(&wc);
|
||||
if atom == 0 {
|
||||
let e = windows::Win32::Foundation::GetLastError();
|
||||
// 1410 = ERROR_CLASS_ALREADY_EXISTS is fine (re-register after a desktop switch).
|
||||
if e.0 != 1410 {
|
||||
tracing::warn!(err = e.0, "force-composed-flip: RegisterClassW failed");
|
||||
}
|
||||
}
|
||||
let hwnd = match CreateWindowExW(
|
||||
WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
|
||||
class,
|
||||
w!(""),
|
||||
WS_POPUP,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
Some(hinst.into()),
|
||||
None,
|
||||
) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let le = windows::Win32::Foundation::GetLastError();
|
||||
tracing::warn!(err = %format!("{e:?}"), last = le.0,
|
||||
"force-composed-flip: CreateWindowExW failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// alpha=1: technically visible (so it disqualifies independent-flip) but imperceptible.
|
||||
let _ = SetLayeredWindowAttributes(hwnd, windows::Win32::Foundation::COLORREF(0), 1, LWA_ALPHA);
|
||||
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
Some(HWND_TOPMOST),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
|
||||
);
|
||||
Some(hwnd)
|
||||
}
|
||||
|
||||
unsafe fn run(stop: Arc<AtomicBool>) {
|
||||
let mut cur_desktop: Option<String> = None;
|
||||
let mut hwnd: Option<HWND> = None;
|
||||
let mut ticks: u32 = 0;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// Follow the input desktop: if it changed (Default↔Winlogon), re-attach this thread and
|
||||
// recreate the window there (a window is bound to the desktop it was created on).
|
||||
let name = input_desktop_name();
|
||||
if name != cur_desktop {
|
||||
if let Some(h) = hwnd.take() {
|
||||
let _ = DestroyWindow(h);
|
||||
}
|
||||
if let Ok(desk) = OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
false,
|
||||
DESKTOP_ACCESS_FLAGS(0x1000_0000), // GENERIC_ALL (incl. DESKTOP_CREATEWINDOW=0x0002)
|
||||
) {
|
||||
if SetThreadDesktop(desk).is_ok() {
|
||||
hwnd = make_overlay();
|
||||
tracing::info!(desktop = ?name, created = hwnd.is_some(),
|
||||
"force-composed-flip: overlay (re)created on input desktop");
|
||||
}
|
||||
// Leak `desk` while it's the thread desktop (closing the current thread desktop is UB).
|
||||
}
|
||||
cur_desktop = name;
|
||||
}
|
||||
// Re-assert topmost periodically (other windows on the secure desktop can push us down) and
|
||||
// pump our message queue so the window stays responsive/composited.
|
||||
if let Some(h) = hwnd {
|
||||
let _ = SetWindowPos(
|
||||
h,
|
||||
Some(HWND_TOPMOST),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
|
||||
);
|
||||
let mut msg = MSG::default();
|
||||
while PeekMessageW(&mut msg, Some(h), 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
ticks = ticks.wrapping_add(1);
|
||||
let _ = ticks;
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
if let Some(h) = hwnd.take() {
|
||||
let _ = DestroyWindow(h);
|
||||
}
|
||||
tracing::info!("force-composed-flip overlay stopped");
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//! Input-desktop watcher (Windows) — the authoritative "normal vs secure desktop" signal for the
|
||||
//! 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
|
||||
//! lock screen and the login screen, and back to "Default" for the normal session. WGC captures only
|
||||
//! the normal desktop; DDA-as-SYSTEM captures the secure one. A dedicated thread polls the input
|
||||
//! 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.
|
||||
|
||||
// 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::Arc;
|
||||
use std::time::Duration;
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::System::StationsAndDesktops::{
|
||||
CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, DESKTOP_ACCESS_FLAGS,
|
||||
DESKTOP_CONTROL_FLAGS, UOI_NAME,
|
||||
};
|
||||
|
||||
/// The normal interactive desktop ("Default") — WGC capture applies.
|
||||
pub const DESKTOP_NORMAL: u8 = 0;
|
||||
/// The secure desktop ("Winlogon": UAC / lock / login) — DDA-as-SYSTEM capture applies.
|
||||
pub const DESKTOP_SECURE: u8 = 1;
|
||||
|
||||
/// Polls the input-desktop name on its own thread and publishes [`DESKTOP_NORMAL`]/[`DESKTOP_SECURE`].
|
||||
pub struct DesktopWatcher {
|
||||
state: Arc<AtomicU8>,
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl DesktopWatcher {
|
||||
pub fn start() -> Self {
|
||||
// Compute the CURRENT desktop synchronously before returning, so the first reader (the capture
|
||||
// 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
|
||||
// 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() } {
|
||||
DESKTOP_SECURE
|
||||
} else {
|
||||
DESKTOP_NORMAL
|
||||
};
|
||||
let state = Arc::new(AtomicU8::new(initial));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let s = state.clone();
|
||||
let st = stop.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("desktop-watch".into())
|
||||
.spawn(move || {
|
||||
// Debounce: only publish a change after the raw reading has been stable for several
|
||||
// polls. The input desktop flaps Default↔Winlogon transiently during a lock/UAC
|
||||
// transition; publishing every flap makes the capture mux thrash (rebuild storms).
|
||||
const STABLE_POLLS: u32 = 4; // ~80ms
|
||||
let mut published = initial;
|
||||
let mut candidate = initial;
|
||||
let mut stable = 0u32;
|
||||
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() } {
|
||||
DESKTOP_SECURE
|
||||
} else {
|
||||
DESKTOP_NORMAL
|
||||
};
|
||||
if v == candidate {
|
||||
stable = stable.saturating_add(1);
|
||||
} else {
|
||||
candidate = v;
|
||||
stable = 1;
|
||||
}
|
||||
if stable >= STABLE_POLLS && candidate != published {
|
||||
s.store(candidate, Ordering::Release);
|
||||
published = candidate;
|
||||
tracing::info!(
|
||||
desktop = if candidate == DESKTOP_SECURE {
|
||||
"Winlogon(secure)"
|
||||
} else {
|
||||
"Default"
|
||||
},
|
||||
"input desktop changed (debounced)"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
DesktopWatcher { state, stop }
|
||||
}
|
||||
|
||||
/// The shared atomic ([`DESKTOP_NORMAL`]/[`DESKTOP_SECURE`]) for the capture mux to read.
|
||||
pub fn state(&self) -> Arc<AtomicU8> {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
/// True when the secure (Winlogon) desktop is the input desktop right now.
|
||||
pub fn is_secure(&self) -> bool {
|
||||
self.state.load(Ordering::Acquire) == DESKTOP_SECURE
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DesktopWatcher {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the current input desktop is "Winlogon" (the secure desktop). Best-effort: if the desktop
|
||||
/// can't be opened or named, report not-secure (the safe default — keep WGC/normal capture).
|
||||
pub(crate) unsafe fn is_secure_desktop() -> bool {
|
||||
let desk = match OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
false,
|
||||
DESKTOP_ACCESS_FLAGS(DESKTOP_READOBJECTS),
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut buf = [0u16; 64];
|
||||
let mut needed = 0u32;
|
||||
let ok = GetUserObjectInformationW(
|
||||
HANDLE(desk.0),
|
||||
UOI_NAME,
|
||||
Some(buf.as_mut_ptr() as *mut _),
|
||||
(buf.len() * 2) as u32,
|
||||
Some(&mut needed),
|
||||
)
|
||||
.is_ok();
|
||||
let _ = CloseDesktop(desk);
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
let name = String::from_utf16_lossy(&buf);
|
||||
name.trim_end_matches('\u{0}')
|
||||
.eq_ignore_ascii_case("Winlogon")
|
||||
}
|
||||
|
||||
/// `DESKTOP_READOBJECTS` access right (the windows crate exposes it as a typed flag; we need the raw
|
||||
/// bit for `OpenInputDesktop`'s access mask).
|
||||
const DESKTOP_READOBJECTS: u32 = 0x0001;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver runs in a restricted WUDFHost
|
||||
//! token that canNOT create named kernel objects, so — exactly like the gamepad UMDF drivers
|
||||
//! (`inject/dualsense_windows.rs`) — the HOST (privileged) CREATES the shared header + frame-ready
|
||||
//! event + ring of keyed-mutex textures (`Global\` names, permissive `D:(A;;GA;;;WD)` SDDL) on the
|
||||
//! discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring
|
||||
//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver's WUDFHost canNOT create named
|
||||
//! kernel objects, so — exactly like the gamepad UMDF drivers (`inject/dualsense_windows.rs`) — the
|
||||
//! HOST (privileged) CREATES the shared header + frame-ready event + ring of keyed-mutex textures
|
||||
//! (`Global\` names, scoped `D:(A;;GA;;;SY)(A;;GA;;;LS)` to SYSTEM + the driver's LocalService host —
|
||||
//! see `shared_object_sa`) on the discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring
|
||||
//! straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook. Gated by
|
||||
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
|
||||
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
||||
@@ -236,13 +236,17 @@ pub struct IddPushCapturer {
|
||||
// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`.
|
||||
unsafe impl Send for IddPushCapturer {}
|
||||
|
||||
/// Build a permissive (Everyone:GenericAll) `SECURITY_ATTRIBUTES` so the restricted WUDFHost driver
|
||||
/// can OPEN the host-created objects — the same `D:(A;;GA;;;WD)` SDDL the gamepad shared section uses.
|
||||
/// The returned `psd` backing must outlive `sa`; both are dropped when the process exits.
|
||||
unsafe fn permissive_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
|
||||
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM** (the host creates+publishes the
|
||||
/// shared event + texture ring) and **LocalService** (the account the pf_vdisplay WUDFHost runs under,
|
||||
/// which consumes them) — `D:(A;;GA;;;SY)(A;;GA;;;LS)`, the same scoping as the gamepad section. The
|
||||
/// old SDDL granted **Everyone** (`WD`), which let any local user open the `Global\pfvd-*` objects and
|
||||
/// read captured screen frames (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29):
|
||||
/// the WUDFHost token is `S-1-5-19` (LocalService), SYSTEM integrity, zero restricted SIDs — so SY+LS
|
||||
/// suffices for the driver and excludes normal user processes. `psd` must outlive `sa`.
|
||||
unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:(A;;GA;;;WD)"),
|
||||
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
@@ -269,7 +273,7 @@ impl IddPushCapturer {
|
||||
h: u32,
|
||||
format: DXGI_FORMAT,
|
||||
) -> Result<Vec<HostSlot>> {
|
||||
let (sa, _psd) = permissive_sa()?;
|
||||
let (sa, _psd) = shared_object_sa()?;
|
||||
let mut slots = Vec::new();
|
||||
for k in 0..RING_LEN {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
@@ -375,7 +379,7 @@ impl IddPushCapturer {
|
||||
// SAFETY: one block over the whole ring setup; every operation in it is sound:
|
||||
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
|
||||
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
||||
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `permissive_sa`, `CreateFileMappingW`,
|
||||
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`,
|
||||
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned
|
||||
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names
|
||||
// are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid
|
||||
@@ -421,7 +425,7 @@ impl IddPushCapturer {
|
||||
.context("EnumAdapterByLuid(render adapter) for IDD push")?;
|
||||
let (device, context) = make_device(&adapter).context("make_device for IDD push")?;
|
||||
|
||||
let (sa, _psd) = permissive_sa()?;
|
||||
let (sa, _psd) = shared_object_sa()?;
|
||||
let bytes = std::mem::size_of::<SharedHeader>().max(64);
|
||||
|
||||
// Header.
|
||||
|
||||
@@ -1,816 +0,0 @@
|
||||
//! Windows.Graphics.Capture (WGC) capture backend — the HDR/animation-correct path.
|
||||
//!
|
||||
//! Why WGC over DXGI Desktop Duplication: DDA duplicates only the DWM-composed primary surface, so
|
||||
//! HDR desktop animations the OS routes onto hardware overlay / independent-flip / MPO planes (Start
|
||||
//! menu, Win11 Mica/acrylic, window resize) never enter the surface DDA reads — the stream shows a
|
||||
//! frozen desktop ("broken HDR animations"). Engaging WGC capture pulls that content back through DWM
|
||||
//! composition, so the surface WGC hands back contains the animations. WGC also has no
|
||||
//! ACCESS_LOST-on-overlay-flip churn.
|
||||
//!
|
||||
//! It reuses the rest of the pipeline UNCHANGED: the frame's GPU texture (the OS already composited
|
||||
//! the cursor into it — `IsCursorCaptureEnabled(true)`) goes through the same scRGB→BT.2020-PQ shader
|
||||
//! ([`super::dxgi::HdrConverter`]) into a host-owned `R10G10B10A2` texture (HDR) or is copied into a
|
||||
//! BGRA texture (SDR), which is handed to NVENC zero-copy (registered by pointer, encoded in place).
|
||||
//! Shares the D3D11 device with NVENC via `FramePayload::D3d11`.
|
||||
//!
|
||||
//! 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).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::dxgi::{
|
||||
find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter,
|
||||
HdrP010Converter, VideoConverter, WinCaptureTarget,
|
||||
};
|
||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::core::{IInspectable, Interface};
|
||||
use windows::Foundation::{TimeSpan, TypedEventHandler};
|
||||
use windows::Graphics::Capture::{
|
||||
Direct3D11CaptureFrame, Direct3D11CaptureFramePool, GraphicsCaptureItem, GraphicsCaptureSession,
|
||||
};
|
||||
use windows::Graphics::DirectX::DirectXPixelFormat;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView, ID3D11ShaderResourceView,
|
||||
ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_TEXTURE2D_DESC,
|
||||
D3D11_USAGE_DEFAULT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_FORMAT_R10G10B10A2_UNORM,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{IDXGIDevice, IDXGIOutput6};
|
||||
use windows::Win32::Security::{ImpersonateLoggedOnUser, RevertToSelf};
|
||||
use windows::Win32::System::RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken};
|
||||
use windows::Win32::System::WinRT::Direct3D11::{
|
||||
CreateDirect3D11DeviceFromDXGIDevice, IDirect3DDxgiInterfaceAccess,
|
||||
};
|
||||
use windows::Win32::System::WinRT::Graphics::Capture::IGraphicsCaptureItemInterop;
|
||||
use windows::Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED};
|
||||
|
||||
/// Output texture ring depth. The encode loop pipelines one frame deep (NVENC encodes frame N while
|
||||
/// the capturer produces N+1), so two live textures suffice; three gives headroom against a slow
|
||||
/// `lock_bitstream` and matches the WGC frame-pool depth.
|
||||
// Sized for the deep encode pipeline (`PUNKTFUNK_ENCODE_DEPTH`, default 4, clamped ≤ 6): up to DEPTH
|
||||
// frames are in flight in NVENC at once, so the HDR convert ring and the SDR held-frame set must each
|
||||
// keep DEPTH(+headroom) live textures, and the WGC pool needs spare buffers beyond what we hold.
|
||||
const OUT_RING: usize = 8;
|
||||
|
||||
/// SDR zero-copy: how many recent WGC frames to keep alive so NVENC can encode the pool texture in
|
||||
/// place (no `CopyResource`). Each in-flight encode reads a distinct frame, so this must exceed the
|
||||
/// pipeline depth; the oldest is released once `HELD_FRAMES` newer ones exist.
|
||||
const HELD_FRAMES: usize = 8;
|
||||
/// WGC frame-pool buffer count. Must exceed `HELD_FRAMES` so the compositor always has free buffers
|
||||
/// to render into while we hold frames for in-place (zero-copy) SDR encode.
|
||||
const WGC_POOL_BUFFERS: i32 = 10;
|
||||
|
||||
/// The host runs as SYSTEM (so the DDA secure-desktop path works), but WGC will NOT activate under
|
||||
/// the SYSTEM account (`CreateForMonitor` → 0x80070424). Impersonate the interactive console user
|
||||
/// for the WGC activation. Returns the user token (the caller reverts + closes it after activation)
|
||||
/// or `None` (no active user, or the host already runs AS the user — WTSQueryUserToken then fails and
|
||||
/// WGC works without impersonation). SYSTEM-only; harmless under a user-token host.
|
||||
unsafe fn impersonate_active_user() -> Option<HANDLE> {
|
||||
let session = WTSGetActiveConsoleSessionId();
|
||||
if session == 0xFFFF_FFFF {
|
||||
return None;
|
||||
}
|
||||
let mut token = HANDLE::default();
|
||||
if WTSQueryUserToken(session, &mut token).is_ok() {
|
||||
if ImpersonateLoggedOnUser(token).is_ok() {
|
||||
return Some(token);
|
||||
}
|
||||
let _ = CloseHandle(token);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// RAII: reverts the WGC-activation impersonation when it drops (covers every `?` early-return).
|
||||
struct Deimpersonate(Option<HANDLE>);
|
||||
impl Drop for Deimpersonate {
|
||||
fn drop(&mut self) {
|
||||
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 {
|
||||
let _ = RevertToSelf();
|
||||
let _ = CloseHandle(tok);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal from the free-threaded FrameArrived callback to the encode thread: a monotonically
|
||||
/// increasing count of arrived frames + a condvar to wake `next_frame`. The encode thread tracks how
|
||||
/// many it has consumed; `TryGetNextFrame` is called exactly `available - consumed` times so we never
|
||||
/// hit the empty-pool ambiguity, and draining to the newest keeps latency at one frame.
|
||||
struct WgcSignal {
|
||||
available: AtomicU64,
|
||||
mtx: Mutex<()>,
|
||||
cv: Condvar,
|
||||
}
|
||||
|
||||
pub struct WgcCapturer {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
// WGC objects — kept alive for the session's lifetime.
|
||||
pool: Direct3D11CaptureFramePool,
|
||||
session: GraphicsCaptureSession,
|
||||
_item: GraphicsCaptureItem,
|
||||
_frame_arrived_token: i64,
|
||||
signal: Arc<WgcSignal>,
|
||||
consumed: u64,
|
||||
|
||||
width: u32,
|
||||
height: u32,
|
||||
timeout_ms: u64,
|
||||
first_frame: bool,
|
||||
|
||||
hdr: bool,
|
||||
/// The source display's static HDR mastering metadata (ST.2086 + content light level), read from
|
||||
/// `IDXGIOutput6::GetDesc1` at open when the output is HDR. Forwarded to the encoder (in-band SEI)
|
||||
/// and the client (0xCE) by the stream loop. `None` when SDR. (The helper relay path also encodes,
|
||||
/// so this is what gives the secure/normal-desktop HDR stream its mastering SEI.)
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
hdr_conv: Option<HdrConverter>,
|
||||
fp16_src: Option<ID3D11Texture2D>,
|
||||
fp16_srv: Option<ID3D11ShaderResourceView>,
|
||||
/// `PUNKTFUNK_HDR_SHADER_P010` path: emit P010 (BT.2020 PQ 10-bit limited range) DIRECTLY from our
|
||||
/// own shader (`HdrP010Converter`) so NVENC takes native P010 and skips its SM-side RGB→YUV CSC.
|
||||
/// Gated by [`hdr_shader_p010_enabled`] AND `self.hdr`; `None`/empty when off → the existing R10 +
|
||||
/// VideoProcessor paths run unchanged. `p010_disabled` latches a runtime failure (e.g. a driver
|
||||
/// that rejects the planar plane RTV) so we fall back to the R10 path and stop retrying.
|
||||
hdr_p010_conv: Option<HdrP010Converter>,
|
||||
p010_out: Vec<ID3D11Texture2D>,
|
||||
p010_idx: usize,
|
||||
p010_disabled: bool,
|
||||
/// Ring of host-owned output textures (BGRA for SDR, R10G10B10A2 for HDR), rotated per processed
|
||||
/// frame. A ring — not one texture — is required because the encode loop is PIPELINED: NVENC
|
||||
/// encodes frame N (in place, registered by pointer) while this capturer produces frame N+1, so
|
||||
/// N+1 must land in a DIFFERENT texture or it clobbers the in-flight encode. (`fp16_src` stays
|
||||
/// single: it's only touched within the D3D11 immediate context, whose op ordering already
|
||||
/// serializes the convert's read against the next copy's write — NVENC's async engine read is the
|
||||
/// only consumer that escapes that ordering, and it reads the ring output, never `fp16_src`.)
|
||||
out_ring: Vec<ID3D11Texture2D>,
|
||||
ring_idx: usize,
|
||||
/// Video-processor RGB→YUV converter (off the 3D engine where possible) + its NV12/P010 output
|
||||
/// ring. Preferred path: the OS-composited capture (cursor already in it) is converted DIRECTLY to
|
||||
/// NVENC's native YUV — no `CopyResource`, no cursor draw, and NVENC skips its internal RGB→YUV.
|
||||
/// `None`/error → falls back to the legacy SDR-zero-copy / HDR-shader paths.
|
||||
video_conv: Option<VideoConverter>,
|
||||
yuv_out: Vec<ID3D11Texture2D>,
|
||||
yuv_idx: usize,
|
||||
yuv_is_hdr: bool,
|
||||
vp_disabled: bool,
|
||||
/// SDR zero-copy: the recent WGC frames we hand to NVENC in place. Held so the pool doesn't
|
||||
/// recycle the texture mid-encode; the oldest is released once `HELD_FRAMES` newer ones exist.
|
||||
held: VecDeque<Direct3D11CaptureFrame>,
|
||||
/// Last presentable GPU texture + format, repeated when no new frame arrived (static desktop).
|
||||
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
|
||||
|
||||
/// Owns the SudoVDA keepalive once attached (after WGC is confirmed open) — dropping the capturer
|
||||
/// then REMOVEs the virtual output. `None` between open and attach so a WGC-open failure leaves
|
||||
/// the keepalive with the caller for the DDA fallback.
|
||||
_keepalive: Option<Box<dyn Send>>,
|
||||
}
|
||||
|
||||
// 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 {}
|
||||
|
||||
impl WgcCapturer {
|
||||
/// Open WGC capture. Does NOT take the keepalive — the caller attaches it via
|
||||
/// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the
|
||||
/// keepalive with the caller to hand to the DDA fallback.
|
||||
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 {
|
||||
// WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem
|
||||
// activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized"
|
||||
// / "changed mode" (another component on this thread may have init'd a compatible apartment).
|
||||
let ro = RoInitialize(RO_INIT_MULTITHREADED);
|
||||
// Impersonate the interactive user for the duration of WGC activation (host runs as
|
||||
// SYSTEM; WGC won't activate under SYSTEM). Reverted by the guard's Drop on return. The
|
||||
// WGC objects, once created, are accessed from the (SYSTEM) encode thread thereafter.
|
||||
let imp = impersonate_active_user();
|
||||
let _deimp = Deimpersonate(imp);
|
||||
tracing::info!(ro_result = ?ro, impersonated = imp.is_some(), "WGC: RoInitialize(MTA)");
|
||||
// The SudoVDA output appears a beat after the display is created — settle-retry like DDA.
|
||||
let deadline = Instant::now() + Duration::from_millis(2000);
|
||||
let (adapter, output) = loop {
|
||||
if let Some(n) = crate::win_display::resolve_gdi_name(target.target_id) {
|
||||
if let Ok(found) = find_output(&n) {
|
||||
break found;
|
||||
}
|
||||
}
|
||||
if let Ok(found) = find_output(&target.gdi_name) {
|
||||
break found;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"WGC: no DXGI output for SudoVDA target {} yet",
|
||||
target.target_id
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
|
||||
let (device, context) = make_device(&adapter)?;
|
||||
let od = output.GetDesc().context("output GetDesc")?;
|
||||
let hmonitor = od.Monitor;
|
||||
|
||||
// HDR iff the output's colour space is BT.2020 PQ (G2084) — matches the DDA FP16 detection.
|
||||
// From the same desc, read the source display's mastering metadata (ST.2086) when HDR.
|
||||
let desc1 = output
|
||||
.cast::<IDXGIOutput6>()
|
||||
.ok()
|
||||
.and_then(|o6| o6.GetDesc1().ok());
|
||||
let hdr = desc1
|
||||
.as_ref()
|
||||
.map(|d1| d1.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)
|
||||
.unwrap_or(false);
|
||||
let hdr_meta = if hdr {
|
||||
desc1.as_ref().map(|d| {
|
||||
crate::hdr::hdr_meta_from_display(
|
||||
(d.RedPrimary[0], d.RedPrimary[1]),
|
||||
(d.GreenPrimary[0], d.GreenPrimary[1]),
|
||||
(d.BluePrimary[0], d.BluePrimary[1]),
|
||||
(d.WhitePoint[0], d.WhitePoint[1]),
|
||||
d.MaxLuminance,
|
||||
d.MinLuminance,
|
||||
0, // MaxCLL: GetDesc1 has no content light level (Apollo zeroes it)
|
||||
0, // MaxFALL
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Wrap our D3D11 device as a WinRT IDirect3DDevice so the frame pool allocates on it (the
|
||||
// pool textures land on our device → CopyResource + NVENC are same-device, no readback).
|
||||
let dxgi_device: IDXGIDevice = device.cast().context("ID3D11Device as IDXGIDevice")?;
|
||||
let inspectable: IInspectable = CreateDirect3D11DeviceFromDXGIDevice(&dxgi_device)
|
||||
.context("CreateDirect3D11DeviceFromDXGIDevice")?;
|
||||
let d3d_device: windows::Graphics::DirectX::Direct3D11::IDirect3DDevice = inspectable
|
||||
.cast()
|
||||
.context("IInspectable as IDirect3DDevice")?;
|
||||
|
||||
tracing::info!(hdr, "WGC: device ready, creating capture item");
|
||||
// GraphicsCaptureItem for the monitor (the SudoVDA output enumerates as a normal monitor).
|
||||
let interop: IGraphicsCaptureItemInterop =
|
||||
windows::core::factory::<GraphicsCaptureItem, IGraphicsCaptureItemInterop>()
|
||||
.context("GraphicsCaptureItem interop factory")?;
|
||||
let item: GraphicsCaptureItem = interop
|
||||
.CreateForMonitor(hmonitor)
|
||||
.context("CreateForMonitor")?;
|
||||
let size = item.Size().context("item Size")?;
|
||||
let (width, height) = (size.Width.max(0) as u32, size.Height.max(0) as u32);
|
||||
tracing::info!(
|
||||
width,
|
||||
height,
|
||||
"WGC: capture item created, creating frame pool"
|
||||
);
|
||||
|
||||
let pixel_format = if hdr {
|
||||
DirectXPixelFormat::R16G16B16A16Float // scRGB FP16 — same surface DDA gives on HDR
|
||||
} else {
|
||||
DirectXPixelFormat::B8G8R8A8UIntNormalized
|
||||
};
|
||||
// Extra buffers: SDR zero-copy holds the last `HELD_FRAMES` frames (encoded in place), so
|
||||
// the pool needs headroom beyond that for the producer to keep rendering at 240 Hz.
|
||||
let pool = Direct3D11CaptureFramePool::CreateFreeThreaded(
|
||||
&d3d_device,
|
||||
pixel_format,
|
||||
WGC_POOL_BUFFERS,
|
||||
size,
|
||||
)
|
||||
.context("CreateFreeThreaded frame pool")?;
|
||||
|
||||
let signal = Arc::new(WgcSignal {
|
||||
available: AtomicU64::new(0),
|
||||
mtx: Mutex::new(()),
|
||||
cv: Condvar::new(),
|
||||
});
|
||||
let sig = signal.clone();
|
||||
let handler = TypedEventHandler::<Direct3D11CaptureFramePool, IInspectable>::new(
|
||||
move |_pool, _arg| {
|
||||
sig.available.fetch_add(1, Ordering::Release);
|
||||
sig.cv.notify_one();
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
let token = pool.FrameArrived(&handler).context("FrameArrived")?;
|
||||
|
||||
tracing::info!("WGC: creating capture session");
|
||||
let session = pool
|
||||
.CreateCaptureSession(&item)
|
||||
.context("CreateCaptureSession")?;
|
||||
// OS composites the cursor into the frame (HDR-correct, no manual composite pass).
|
||||
let _ = session.SetIsCursorCaptureEnabled(true);
|
||||
// Drop the yellow capture border (best-effort — older builds reject it).
|
||||
let _ = session.SetIsBorderRequired(false);
|
||||
// Lift the 60 Hz cap: allow up to the client's refresh (Win11 24H2+; below that this is a
|
||||
// no-op and WGC caps ~60). 100 ns ticks per frame.
|
||||
let refresh = preferred
|
||||
.map(|(_, _, hz)| hz)
|
||||
.filter(|&hz| hz > 0)
|
||||
.unwrap_or(60);
|
||||
let ticks = (10_000_000i64 / refresh.max(1) as i64).max(1);
|
||||
let _ = session.SetMinUpdateInterval(TimeSpan { Duration: ticks });
|
||||
tracing::info!("WGC: StartCapture");
|
||||
session.StartCapture().context("StartCapture")?;
|
||||
// WGC fires FrameArrived on CHANGE; a static desktop may never deliver the first frame
|
||||
// (→ black, then the next_frame deadline ends the session). Nudge the cursor onto the
|
||||
// output to force the first composition change, exactly like the DDA path does.
|
||||
nudge_cursor_onto(&output);
|
||||
|
||||
let timeout_ms = (2000 / refresh.max(1) as u64).max(8);
|
||||
tracing::info!(
|
||||
width,
|
||||
height,
|
||||
hdr,
|
||||
refresh,
|
||||
"WGC capture started ({})",
|
||||
if hdr {
|
||||
"HDR FP16→BT.2020 PQ"
|
||||
} else {
|
||||
"SDR BGRA"
|
||||
}
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
context,
|
||||
pool,
|
||||
session,
|
||||
_item: item,
|
||||
_frame_arrived_token: token,
|
||||
signal,
|
||||
consumed: 0,
|
||||
width,
|
||||
height,
|
||||
timeout_ms,
|
||||
first_frame: true,
|
||||
hdr,
|
||||
hdr_meta,
|
||||
hdr_conv: None,
|
||||
fp16_src: None,
|
||||
fp16_srv: None,
|
||||
hdr_p010_conv: None,
|
||||
p010_out: Vec::new(),
|
||||
p010_idx: 0,
|
||||
p010_disabled: false,
|
||||
out_ring: Vec::new(),
|
||||
ring_idx: 0,
|
||||
video_conv: None,
|
||||
yuv_out: Vec::new(),
|
||||
yuv_idx: 0,
|
||||
yuv_is_hdr: false,
|
||||
vp_disabled: std::env::var_os("PUNKTFUNK_NO_VIDEO_PROCESSOR").is_some(),
|
||||
held: VecDeque::new(),
|
||||
last_present: None,
|
||||
_keepalive: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Take ownership of the SudoVDA keepalive once the WGC session is confirmed open.
|
||||
pub fn attach_keepalive(&mut self, keepalive: Box<dyn Send>) {
|
||||
self._keepalive = Some(keepalive);
|
||||
}
|
||||
|
||||
/// Block until a new frame arrives (cv), then drain `TryGetNextFrame` to the NEWEST queued frame
|
||||
/// (skip stale → one-frame latency). Returns `None` on timeout (no new frame → caller repeats).
|
||||
fn wait_and_drain(&mut self) -> Option<Direct3D11CaptureFrame> {
|
||||
let wait_ms = if self.first_frame {
|
||||
2000
|
||||
} else {
|
||||
self.timeout_ms
|
||||
};
|
||||
{
|
||||
let mut g = self.signal.mtx.lock().unwrap();
|
||||
while self.signal.available.load(Ordering::Acquire) <= self.consumed {
|
||||
let (ng, res) = self
|
||||
.signal
|
||||
.cv
|
||||
.wait_timeout(g, Duration::from_millis(wait_ms))
|
||||
.unwrap();
|
||||
g = ng;
|
||||
if res.timed_out() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = self.signal.available.load(Ordering::Acquire);
|
||||
let mut last = None;
|
||||
while self.consumed < target {
|
||||
if let Ok(f) = self.pool.TryGetNextFrame() {
|
||||
last = Some(f);
|
||||
}
|
||||
self.consumed += 1;
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
unsafe fn ensure_fp16_src(&mut self) -> Result<()> {
|
||||
if self.fp16_src.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let desc = tex_desc(
|
||||
self.width,
|
||||
self.height,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
(D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
);
|
||||
let mut t = None;
|
||||
self.device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D(wgc fp16 src)")?;
|
||||
let t = t.context("fp16 src")?;
|
||||
let mut srv = None;
|
||||
self.device
|
||||
.CreateShaderResourceView(&t, None, Some(&mut srv))?;
|
||||
self.fp16_srv = Some(srv.context("fp16 srv")?);
|
||||
self.fp16_src = Some(t);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lazily allocate the HDR output texture ring (R10G10B10A2, the convert pass's render target →
|
||||
/// NVENC input), `RENDER_TARGET`-bindable. SDR is zero-copy (encodes the WGC pool texture in
|
||||
/// place) and uses no ring.
|
||||
unsafe fn ensure_out_ring(
|
||||
&mut self,
|
||||
format: windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT,
|
||||
) -> Result<()> {
|
||||
if !self.out_ring.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let desc = tex_desc(
|
||||
self.width,
|
||||
self.height,
|
||||
format,
|
||||
D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
);
|
||||
for _ in 0..OUT_RING {
|
||||
let mut t = None;
|
||||
self.device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D(wgc out ring)")?;
|
||||
self.out_ring.push(t.context("wgc out ring tex")?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert `input` (the OS-composited WGC pool texture: BGRA or scRGB FP16) → NVENC's native YUV
|
||||
/// (NV12 / P010) on the video processor. Returns the YUV texture (from a ring so consecutive
|
||||
/// encodes don't collide), or `None` to fall back to the legacy RGB paths.
|
||||
unsafe fn convert_to_yuv(
|
||||
&mut self,
|
||||
input: &ID3D11Texture2D,
|
||||
hdr: bool,
|
||||
) -> Option<ID3D11Texture2D> {
|
||||
if self.vp_disabled {
|
||||
return None;
|
||||
}
|
||||
if self.video_conv.is_none() || self.yuv_out.is_empty() || self.yuv_is_hdr != hdr {
|
||||
self.video_conv = None;
|
||||
self.yuv_out.clear();
|
||||
self.yuv_idx = 0;
|
||||
let vc = match VideoConverter::new(
|
||||
&self.device,
|
||||
&self.context,
|
||||
self.width,
|
||||
self.height,
|
||||
hdr,
|
||||
) {
|
||||
Ok(vc) => vc,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"WGC: video processor unavailable — falling back to RGB path");
|
||||
self.vp_disabled = true;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let fmt = if hdr {
|
||||
windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_P010
|
||||
} else {
|
||||
windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_NV12
|
||||
};
|
||||
let desc = tex_desc(
|
||||
self.width,
|
||||
self.height,
|
||||
fmt,
|
||||
D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
);
|
||||
for _ in 0..OUT_RING {
|
||||
let mut t = None;
|
||||
if self
|
||||
.device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("WGC: CreateTexture2D(YUV) failed — falling back to RGB path");
|
||||
self.vp_disabled = true;
|
||||
self.yuv_out.clear();
|
||||
return None;
|
||||
}
|
||||
let Some(tex) = t else {
|
||||
self.vp_disabled = true;
|
||||
self.yuv_out.clear();
|
||||
return None;
|
||||
};
|
||||
self.yuv_out.push(tex);
|
||||
}
|
||||
self.video_conv = Some(vc);
|
||||
self.yuv_is_hdr = hdr;
|
||||
tracing::info!(
|
||||
hdr,
|
||||
"WGC: video-processor YUV path active ({})",
|
||||
if hdr { "P010" } else { "NV12" }
|
||||
);
|
||||
}
|
||||
let slot = self.yuv_idx;
|
||||
self.yuv_idx = (self.yuv_idx + 1) % self.yuv_out.len();
|
||||
let out = self.yuv_out[slot].clone();
|
||||
if let Err(e) = self.video_conv.as_ref()?.convert(input, &out) {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"WGC: VideoProcessorBlt failed — falling back to RGB path");
|
||||
self.vp_disabled = true;
|
||||
self.video_conv = None;
|
||||
self.yuv_out.clear();
|
||||
return None;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_HDR_SHADER_P010` path: convert the OS-composited FP16 scRGB capture DIRECTLY to a
|
||||
/// host-owned P010 texture (BT.2020 PQ, 10-bit limited range) via [`HdrP010Converter`] — two
|
||||
/// shader passes writing the P010 planes. NVENC then takes native P010 and skips its internal
|
||||
/// RGB→YUV CSC. Returns the next ring slot's P010 texture, or `Err` if the converter / a planar
|
||||
/// plane RTV fails (the caller latches `p010_disabled` and falls back to the R10 path).
|
||||
unsafe fn hdr_to_p010(&mut self, src: &ID3D11Texture2D) -> Result<ID3D11Texture2D> {
|
||||
let slot = self.p010_idx;
|
||||
// Lazily allocate the FP16 source (shared with the R10 path) + the P010 output ring.
|
||||
self.ensure_fp16_src()?;
|
||||
let fp16 = self.fp16_src.clone().context("fp16 src")?;
|
||||
self.context.CopyResource(&fp16, src);
|
||||
if self.p010_out.is_empty() {
|
||||
let desc = tex_desc(
|
||||
self.width,
|
||||
self.height,
|
||||
windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_P010,
|
||||
D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
);
|
||||
for _ in 0..OUT_RING {
|
||||
let mut t = None;
|
||||
self.device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D(wgc p010 ring)")?;
|
||||
self.p010_out.push(t.context("wgc p010 ring tex")?);
|
||||
}
|
||||
}
|
||||
self.p010_idx = (self.p010_idx + 1) % self.p010_out.len();
|
||||
let out = self.p010_out[slot].clone();
|
||||
if self.hdr_p010_conv.is_none() {
|
||||
self.hdr_p010_conv = Some(HdrP010Converter::new(&self.device)?);
|
||||
}
|
||||
let srv = self.fp16_srv.clone().context("fp16 srv")?;
|
||||
self.hdr_p010_conv.as_ref().unwrap().convert(
|
||||
&self.device,
|
||||
&self.context,
|
||||
&srv,
|
||||
&out,
|
||||
self.width,
|
||||
self.height,
|
||||
)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
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 {
|
||||
let surface = frame.Surface().context("frame Surface")?;
|
||||
let access: IDirect3DDxgiInterfaceAccess = surface
|
||||
.cast()
|
||||
.context("surface as IDirect3DDxgiInterfaceAccess")?;
|
||||
let src: ID3D11Texture2D = access
|
||||
.GetInterface()
|
||||
.context("GetInterface ID3D11Texture2D")?;
|
||||
|
||||
// GATED P010-shader path (`PUNKTFUNK_HDR_SHADER_P010`): for HDR, emit P010 (BT.2020 PQ
|
||||
// 10-bit limited range) DIRECTLY from our shader so NVENC takes native P010 and skips its
|
||||
// SM-side RGB→YUV CSC. Runs BEFORE the R10 + VideoProcessor path. A converter/plane-RTV
|
||||
// failure latches `p010_disabled` → we fall through to the unchanged R10 path for the rest
|
||||
// of the session. Default OFF → none of this executes and behaviour is byte-for-byte as
|
||||
// today.
|
||||
if self.hdr && !self.p010_disabled && hdr_shader_p010_enabled() {
|
||||
match self.hdr_to_p010(&src) {
|
||||
Ok(p010) => {
|
||||
// The P010 output is host-owned (the ring), and the FP16 CopyResource read
|
||||
// `src` synchronously on the immediate context before the shader passes — so we
|
||||
// do NOT need to hold `frame` past here (unlike the SDR/R10 in-place paths).
|
||||
// Dropping it returns the pool buffer to WGC immediately.
|
||||
drop(frame);
|
||||
self.last_present = Some((p010.clone(), PixelFormat::P010));
|
||||
return Ok(self.d3d11_frame(p010, PixelFormat::P010));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"WGC: HDR P010 shader path failed — disabling it, falling back to R10");
|
||||
self.p010_disabled = true;
|
||||
self.hdr_p010_conv = None;
|
||||
self.p010_out.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preferred path: convert the OS-composited capture (cursor already in it) DIRECTLY to
|
||||
// NVENC's native YUV on the video processor — no CopyResource, no cursor draw, and NVENC
|
||||
// skips its internal RGB→YUV (the contended 3D step). WGC's multi-buffer pool + held set
|
||||
// means reading the pool texture directly does NOT serialize (unlike DDA's single-frame
|
||||
// model). The frame is held until the async Blt finishes. (HDR: the video processor can't
|
||||
// ingest FP16 scRGB, so the Blt fails and we fall back to the R10 path below; the
|
||||
// `PUNKTFUNK_HDR_SHADER_P010` branch above is the off-the-SM HDR path.)
|
||||
if let Some(yuv) = self.convert_to_yuv(&src, self.hdr) {
|
||||
let fmt = if self.hdr {
|
||||
PixelFormat::P010
|
||||
} else {
|
||||
PixelFormat::Nv12
|
||||
};
|
||||
self.last_present = Some((yuv.clone(), fmt));
|
||||
let out = self.d3d11_frame(yuv, fmt);
|
||||
self.held.push_back(frame);
|
||||
while self.held.len() > HELD_FRAMES {
|
||||
self.held.pop_front();
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
// --- fallback (video processor unavailable) ---
|
||||
if self.hdr {
|
||||
// Next ring slot — the in-flight encode reads the slot we handed out last time, so
|
||||
// this capture must land in a different one (see `out_ring`).
|
||||
let slot = self.ring_idx;
|
||||
self.ring_idx = (self.ring_idx + 1) % OUT_RING;
|
||||
// FP16 (cursor already composited by the OS) → BT.2020 PQ 10-bit for NVENC.
|
||||
self.ensure_fp16_src()?;
|
||||
let fp16 = self.fp16_src.clone().context("fp16 src")?;
|
||||
self.context.CopyResource(&fp16, &src);
|
||||
self.ensure_out_ring(DXGI_FORMAT_R10G10B10A2_UNORM)?;
|
||||
let out = self.out_ring[slot].clone();
|
||||
if self.hdr_conv.is_none() {
|
||||
self.hdr_conv = Some(HdrConverter::new(&self.device)?);
|
||||
}
|
||||
let srv = self.fp16_srv.clone().context("fp16 srv")?;
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
self.device
|
||||
.CreateRenderTargetView(&out, None, Some(&mut rtv))?;
|
||||
let rtv = rtv.context("hdr10 rtv")?;
|
||||
self.hdr_conv.as_ref().unwrap().convert(
|
||||
&self.context,
|
||||
&srv,
|
||||
&rtv,
|
||||
self.width,
|
||||
self.height,
|
||||
);
|
||||
self.last_present = Some((out.clone(), PixelFormat::Rgb10a2));
|
||||
Ok(self.d3d11_frame(out, PixelFormat::Rgb10a2))
|
||||
} else {
|
||||
// SDR ZERO-COPY: hand NVENC the WGC pool texture DIRECTLY — no `CopyResource`. The
|
||||
// per-frame copy otherwise queues on the graphics engine behind a GPU-saturating game
|
||||
// and stalls `lock_bitstream` ~20 ms (NVENC sits idle waiting for its input). Encoding
|
||||
// the pool texture in place removes that graphics-queue dependency (Apollo's model).
|
||||
// We must keep the frame alive until its async encode finishes, so retain the last
|
||||
// `HELD_FRAMES`; the pool has spare buffers so the producer never starves.
|
||||
self.last_present = Some((src.clone(), PixelFormat::Bgra));
|
||||
let out = self.d3d11_frame(src, PixelFormat::Bgra);
|
||||
self.held.push_back(frame);
|
||||
while self.held.len() > HELD_FRAMES {
|
||||
self.held.pop_front();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn d3d11_frame(&self, texture: ID3D11Texture2D, format: PixelFormat) -> CapturedFrame {
|
||||
CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
pts_ns: now_ns(),
|
||||
format,
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture,
|
||||
device: self.device.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Capturer for WgcCapturer {
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
self.hdr_meta
|
||||
}
|
||||
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
let overall = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
if let Some(frame) = self.wait_and_drain() {
|
||||
self.first_frame = false;
|
||||
return self.process_frame(frame);
|
||||
}
|
||||
// No new frame within the wait — repeat the last presented frame (static desktop).
|
||||
if let Some((tex, fmt)) = &self.last_present {
|
||||
return Ok(self.d3d11_frame(tex.clone(), *fmt));
|
||||
}
|
||||
if Instant::now() > overall {
|
||||
bail!("no WGC frame within 20s (SudoVDA monitor not lit / no capture access?)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
||||
let target = self.signal.available.load(Ordering::Acquire);
|
||||
if target <= self.consumed {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut last = None;
|
||||
while self.consumed < target {
|
||||
if let Ok(f) = self.pool.TryGetNextFrame() {
|
||||
last = Some(f);
|
||||
}
|
||||
self.consumed += 1;
|
||||
}
|
||||
match last {
|
||||
Some(frame) => self.process_frame(frame).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
// set_active: the trait default (no-op) is correct — WGC keeps its session running across the
|
||||
// active/idle gate (cheap; the frame pool just recycles), like the DDA duplication.
|
||||
}
|
||||
|
||||
impl Drop for WgcCapturer {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.session.Close();
|
||||
let _ = self.pool.Close();
|
||||
// _keepalive drops after, REMOVEing the SudoVDA monitor.
|
||||
}
|
||||
}
|
||||
|
||||
fn tex_desc(
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT,
|
||||
bind: u32,
|
||||
) -> D3D11_TEXTURE2D_DESC {
|
||||
D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: bind,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ns() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
//! Host-side WGC helper relay (Windows two-process secure-desktop design,
|
||||
//! 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
|
||||
//! itself. Instead it spawns `punktfunk-host wgc-helper` in the **interactive user session** (so WGC works)
|
||||
//! via `CreateProcessAsUserW`, with the helper's **stdout** redirected to an anonymous pipe the host
|
||||
//! reads. The helper ships framed Annex-B access units; this module parses them back into AUs the
|
||||
//! host relays onto the live QUIC session (same `EncodedFrame` flow, just sourced over a pipe instead
|
||||
//! of a local encoder). A second pipe carries a tiny **control** channel to the helper (stdin: force
|
||||
//! keyframe), and the helper's **stderr** is forwarded line-by-line into host tracing so its logs are
|
||||
//! visible from the SYSTEM host's console.
|
||||
//!
|
||||
//! 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]`.
|
||||
|
||||
// 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 anyhow::{bail, Context, Result};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::sync::mpsc::{Receiver, SyncSender};
|
||||
use std::sync::Mutex;
|
||||
use windows::core::PWSTR;
|
||||
use windows::Win32::Foundation::SetHandleInformation;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::Foundation::{HANDLE_FLAGS, HANDLE_FLAG_INHERIT};
|
||||
use windows::Win32::Security::{
|
||||
DuplicateTokenEx, SecurityImpersonation, TokenPrimary, SECURITY_ATTRIBUTES, TOKEN_ALL_ACCESS,
|
||||
};
|
||||
use windows::Win32::System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock};
|
||||
use windows::Win32::System::Pipes::CreatePipe;
|
||||
use windows::Win32::System::RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken};
|
||||
use windows::Win32::System::Threading::{
|
||||
CreateProcessAsUserW, TerminateProcess, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT,
|
||||
PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOW,
|
||||
};
|
||||
|
||||
/// Must match [`crate::wgc_helper`]'s `AU_MAGIC` ("PFAU").
|
||||
const AU_MAGIC: u32 = 0x5046_4155;
|
||||
|
||||
/// One access unit relayed from the helper, in the helper's (= the host's, same machine) monotonic
|
||||
/// clock — `pts_ns` is directly comparable to the host's `now_ns()`.
|
||||
pub struct RelayAu {
|
||||
pub data: Vec<u8>,
|
||||
pub pts_ns: u64,
|
||||
pub keyframe: bool,
|
||||
}
|
||||
|
||||
/// A running USER-session WGC helper whose AUs the SYSTEM host relays. Drop kills the child + closes
|
||||
/// the pipes; the reader threads then end on the broken pipe.
|
||||
pub struct HelperRelay {
|
||||
proc: HANDLE,
|
||||
thread: HANDLE,
|
||||
/// Host write end of the helper's stdin — control commands (force keyframe). Mutex so the relay
|
||||
/// can be shared while the encode thread requests keyframes.
|
||||
stdin_w: Mutex<HANDLE>,
|
||||
/// Parsed AUs from the helper's stdout reader thread.
|
||||
rx: Receiver<RelayAu>,
|
||||
}
|
||||
|
||||
// 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 {}
|
||||
// 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
|
||||
/// recovery). Mirrors `enc.request_keyframe()` in the single-process path.
|
||||
const CTL_KEYFRAME: u8 = 0x01;
|
||||
|
||||
impl HelperRelay {
|
||||
/// Spawn the helper in the interactive user session and start relaying its AUs. `target` is the
|
||||
/// SudoVDA output the host already created (captured by GDI name only — the helper never touches
|
||||
/// display topology). `(w, h, hz)` is the negotiated mode; `bitrate_kbps` the negotiated bitrate.
|
||||
pub fn spawn(
|
||||
target: &WinCaptureTarget,
|
||||
mode: (u32, u32, u32),
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
) -> Result<HelperRelay> {
|
||||
let exe = std::env::current_exe().context("current_exe for helper spawn")?;
|
||||
let exe = exe.to_string_lossy().into_owned();
|
||||
let (w, h, hz) = mode;
|
||||
// CreateProcessAsUserW takes a single mutable command line (argv[0] = exe).
|
||||
let cmdline = format!(
|
||||
"\"{exe}\" wgc-helper --gdi \"{}\" --target-id {} --mode {w}x{h}x{hz} --bitrate {bitrate_kbps} --bit-depth {bit_depth}",
|
||||
target.gdi_name, target.target_id
|
||||
);
|
||||
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) }
|
||||
}
|
||||
|
||||
/// Receive the next relayed AU. Distinguishes a `Timeout` (helper slow/stalled — keep waiting)
|
||||
/// from `Disconnected` (helper exited → its stdout closed → reader thread ended → channel
|
||||
/// dropped), which returns *immediately* and means the relay must stop, not spin.
|
||||
pub fn recv_timeout(
|
||||
&self,
|
||||
dur: std::time::Duration,
|
||||
) -> Result<RelayAu, std::sync::mpsc::RecvTimeoutError> {
|
||||
self.rx.recv_timeout(dur)
|
||||
}
|
||||
|
||||
/// Non-blocking receive — used to drain stale buffered AUs (encoded while the secure desktop was
|
||||
/// the live source) before resuming the relay. `Ok` while AUs remain, `Err` once empty.
|
||||
pub fn try_recv(&self) -> Result<RelayAu, std::sync::mpsc::TryRecvError> {
|
||||
self.rx.try_recv()
|
||||
}
|
||||
|
||||
/// Ask the helper's encoder for an IDR on the next frame (client decode recovery). Best-effort:
|
||||
/// a write failure means the helper is gone — the caller's recv loop will see the disconnect.
|
||||
pub fn request_keyframe(&self) {
|
||||
let h = self.stdin_w.lock().unwrap();
|
||||
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 {
|
||||
let _ = windows::Win32::Storage::FileSystem::WriteFile(
|
||||
*h,
|
||||
Some(&[CTL_KEYFRAME]),
|
||||
Some(&mut written),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HelperRelay {
|
||||
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 {
|
||||
// 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).
|
||||
let _ = TerminateProcess(self.proc, 1);
|
||||
let _ = CloseHandle(*self.stdin_w.lock().unwrap());
|
||||
let _ = CloseHandle(self.proc);
|
||||
let _ = CloseHandle(self.thread);
|
||||
}
|
||||
tracing::info!("WGC helper relay torn down");
|
||||
}
|
||||
}
|
||||
|
||||
/// Inheritable anonymous pipe (read, write). The caller marks whichever end the host keeps as
|
||||
/// non-inheritable so the child only inherits its own end.
|
||||
unsafe fn make_pipe() -> Result<(HANDLE, HANDLE)> {
|
||||
let mut read = HANDLE::default();
|
||||
let mut write = HANDLE::default();
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: std::ptr::null_mut(),
|
||||
bInheritHandle: true.into(),
|
||||
};
|
||||
CreatePipe(&mut read, &mut write, Some(&sa), 0).context("CreatePipe")?;
|
||||
Ok((read, write))
|
||||
}
|
||||
|
||||
/// Mark a handle non-inheritable (the host keeps it; the child must not get a copy).
|
||||
unsafe fn no_inherit(h: HANDLE) {
|
||||
let _ = SetHandleInformation(h, HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0));
|
||||
}
|
||||
|
||||
/// Build a child environment block: the target session's block (so DLL/PATH/SystemRoot resolve) with
|
||||
/// this process's `PUNKTFUNK_*` vars overlaid, so the child runs with the SAME settings this process
|
||||
/// has (`PUNKTFUNK_ENCODER=nvenc`, `PUNKTFUNK_ZEROCOPY`, …) instead of the target shell's. Returns a
|
||||
/// UTF-16, double-null-terminated block suitable for `CREATE_UNICODE_ENVIRONMENT`. Shared by the WGC
|
||||
/// helper spawn (here) and the Windows service launching the host into the active session.
|
||||
pub(crate) unsafe fn merged_env_block(user_block: *const u16) -> Vec<u16> {
|
||||
// Parse the user block ("VAR=VALUE\0" … "\0") into entries.
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
if !user_block.is_null() {
|
||||
let mut p = user_block;
|
||||
loop {
|
||||
let mut len = 0isize;
|
||||
while *p.offset(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
if len == 0 {
|
||||
break; // the trailing empty string = end of block
|
||||
}
|
||||
let slice = std::slice::from_raw_parts(p, len as usize);
|
||||
entries.push(String::from_utf16_lossy(slice));
|
||||
p = p.offset(len + 1);
|
||||
}
|
||||
}
|
||||
// Overlay "our" settings — PUNKTFUNK_* and RUST_LOG — dropping whatever the target block had.
|
||||
let is_ours = |k: &str| k.starts_with("PUNKTFUNK_") || k == "RUST_LOG";
|
||||
entries.retain(|e| !is_ours(e.split('=').next().unwrap_or("")));
|
||||
for (k, v) in std::env::vars().filter(|(k, _)| is_ours(k)) {
|
||||
entries.push(format!("{k}={v}"));
|
||||
}
|
||||
// Serialize back to a UTF-16 double-null-terminated block.
|
||||
let mut block: Vec<u16> = Vec::new();
|
||||
for e in entries {
|
||||
block.extend(e.encode_utf16());
|
||||
block.push(0);
|
||||
}
|
||||
block.push(0);
|
||||
block
|
||||
}
|
||||
|
||||
unsafe fn spawn_inner(cmdline: &str, w: u32, h: u32, hz: u32) -> Result<HelperRelay> {
|
||||
// The user token of the active console session (requires the host to be SYSTEM).
|
||||
let session = WTSGetActiveConsoleSessionId();
|
||||
if session == 0xFFFF_FFFF {
|
||||
bail!("no active console session (WTSGetActiveConsoleSessionId)");
|
||||
}
|
||||
let mut user_token = HANDLE::default();
|
||||
WTSQueryUserToken(session, &mut user_token)
|
||||
.context("WTSQueryUserToken (host must run as SYSTEM)")?;
|
||||
|
||||
// A primary token for CreateProcessAsUserW.
|
||||
let mut primary = HANDLE::default();
|
||||
let dup = DuplicateTokenEx(
|
||||
user_token,
|
||||
TOKEN_ALL_ACCESS,
|
||||
None,
|
||||
SecurityImpersonation,
|
||||
TokenPrimary,
|
||||
&mut primary,
|
||||
);
|
||||
let _ = CloseHandle(user_token);
|
||||
dup.context("DuplicateTokenEx(TokenPrimary)")?;
|
||||
|
||||
// The user's environment block (PATH, USERPROFILE, SystemRoot → DLL resolution), MERGED with the
|
||||
// host's PUNKTFUNK_* vars. CreateProcessAsUserW would otherwise give the helper the *user's* env
|
||||
// only, dropping PUNKTFUNK_ENCODER=nvenc / PUNKTFUNK_ZEROCOPY/… that the host runs with — so the
|
||||
// helper would fall back to the software (H.264-only) encoder. We parse the user block, strip any
|
||||
// PUNKTFUNK_* it has, append the host's, and pass the merged block.
|
||||
let mut env_block: *mut core::ffi::c_void = std::ptr::null_mut();
|
||||
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
|
||||
let merged_env = merged_env_block(env_block as *const u16);
|
||||
if !env_block.is_null() {
|
||||
let _ = DestroyEnvironmentBlock(env_block);
|
||||
}
|
||||
|
||||
// Three pipes: stdout (helper→host AUs), stdin (host→helper control), stderr (helper→host logs).
|
||||
let (out_r, out_w) = make_pipe().context("stdout pipe")?;
|
||||
let (in_r, in_w) = make_pipe().context("stdin pipe")?;
|
||||
let (err_r, err_w) = make_pipe().context("stderr pipe")?;
|
||||
// The host keeps out_r / in_w / err_r — none inheritable; the child inherits out_w/in_r/err_w.
|
||||
no_inherit(out_r);
|
||||
no_inherit(in_w);
|
||||
no_inherit(err_r);
|
||||
|
||||
let mut si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
dwFlags: STARTF_USESTDHANDLES,
|
||||
hStdInput: in_r,
|
||||
hStdOutput: out_w,
|
||||
hStdError: err_w,
|
||||
..Default::default()
|
||||
};
|
||||
// WGC needs the interactive desktop.
|
||||
let mut desktop: Vec<u16> = "winsta0\\default\0".encode_utf16().collect();
|
||||
si.lpDesktop = PWSTR(desktop.as_mut_ptr());
|
||||
|
||||
let mut cmd: Vec<u16> = cmdline.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
|
||||
let created = CreateProcessAsUserW(
|
||||
Some(primary),
|
||||
None,
|
||||
Some(PWSTR(cmd.as_mut_ptr())),
|
||||
None,
|
||||
None,
|
||||
true, // inherit handles (the child's std ends)
|
||||
CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW,
|
||||
Some(merged_env.as_ptr() as *const core::ffi::c_void),
|
||||
None,
|
||||
&si,
|
||||
&mut pi,
|
||||
);
|
||||
|
||||
// Clean up regardless of outcome: the child now owns its inherited ends; close our copies.
|
||||
let _ = CloseHandle(out_w);
|
||||
let _ = CloseHandle(in_r);
|
||||
let _ = CloseHandle(err_w);
|
||||
let _ = CloseHandle(primary);
|
||||
|
||||
if let Err(e) = created {
|
||||
let _ = CloseHandle(out_r);
|
||||
let _ = CloseHandle(in_w);
|
||||
let _ = CloseHandle(err_r);
|
||||
return Err(e).context("CreateProcessAsUserW(wgc-helper)");
|
||||
}
|
||||
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.
|
||||
let err_handle = HandleReader(err_r);
|
||||
std::thread::Builder::new()
|
||||
.name("wgc-helper-log".into())
|
||||
.spawn(move || {
|
||||
let r = BufReader::new(err_handle);
|
||||
for line in r.lines() {
|
||||
match line {
|
||||
Ok(l) if !l.trim().is_empty() => tracing::info!(target: "wgc_helper", "{l}"),
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
|
||||
// stdout → parsed AUs. Bounded so a stalled relay applies backpressure (the pipe then fills and
|
||||
// the helper blocks on write — the same backpressure the single-process channel gives).
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<RelayAu>(3);
|
||||
let out_handle = HandleReader(out_r);
|
||||
std::thread::Builder::new()
|
||||
.name("wgc-helper-au".into())
|
||||
.spawn(move || au_reader(out_handle, tx))
|
||||
.ok();
|
||||
|
||||
Ok(HelperRelay {
|
||||
proc: pi.hProcess,
|
||||
thread: pi.hThread,
|
||||
stdin_w: Mutex::new(in_w),
|
||||
rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the AU framing off the helper's stdout and forward each AU. Ends (returns) when the pipe
|
||||
/// breaks (helper exit) or the channel's receiver is dropped (relay torn down).
|
||||
fn au_reader(mut r: HandleReader, tx: SyncSender<RelayAu>) {
|
||||
loop {
|
||||
let mut hdr = [0u8; 4 + 4 + 8 + 1];
|
||||
if r.read_exact(&mut hdr).is_err() {
|
||||
break;
|
||||
}
|
||||
let magic = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
|
||||
if magic != AU_MAGIC {
|
||||
tracing::error!(
|
||||
magic = format!("{magic:#x}"),
|
||||
"WGC helper AU stream desync — aborting relay"
|
||||
);
|
||||
break;
|
||||
}
|
||||
let len = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]) as usize;
|
||||
let pts_ns = u64::from_le_bytes([
|
||||
hdr[8], hdr[9], hdr[10], hdr[11], hdr[12], hdr[13], hdr[14], hdr[15],
|
||||
]);
|
||||
let keyframe = hdr[16] != 0;
|
||||
// Bound the allocation — a corrupt length must not OOM the host. 64 MiB is far above any real
|
||||
// AU (a 5K keyframe is a few MB).
|
||||
if len > 64 * 1024 * 1024 {
|
||||
tracing::error!(len, "WGC helper AU length implausible — aborting relay");
|
||||
break;
|
||||
}
|
||||
let mut data = vec![0u8; len];
|
||||
if r.read_exact(&mut data).is_err() {
|
||||
break;
|
||||
}
|
||||
if tx
|
||||
.send(RelayAu {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break; // relay dropped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on 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 {}
|
||||
impl Read for HandleReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
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 {
|
||||
windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None)
|
||||
};
|
||||
match ok {
|
||||
Ok(()) => Ok(read as usize),
|
||||
// A broken pipe (helper exited) reads as ERROR_BROKEN_PIPE → report EOF (0).
|
||||
Err(_) => Ok(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for HandleReader {
|
||||
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 {
|
||||
let _ = CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this process running as the LOCAL SYSTEM account? Used to decide whether the two-process
|
||||
/// secure-desktop path applies (only SYSTEM can `WTSQueryUserToken` + capture the Winlogon desktop).
|
||||
pub fn running_as_system() -> bool {
|
||||
use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
|
||||
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 {
|
||||
let mut token = HANDLE::default();
|
||||
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut len = 0u32;
|
||||
let _ = GetTokenInformation(token, TokenUser, None, 0, &mut len);
|
||||
if len == 0 {
|
||||
let _ = CloseHandle(token);
|
||||
return false;
|
||||
}
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
let ok = GetTokenInformation(
|
||||
token,
|
||||
TokenUser,
|
||||
Some(buf.as_mut_ptr() as *mut _),
|
||||
len,
|
||||
&mut len,
|
||||
)
|
||||
.is_ok();
|
||||
let _ = CloseHandle(token);
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
let tu = &*(buf.as_ptr() as *const TOKEN_USER);
|
||||
// The well-known LocalSystem SID is S-1-5-18.
|
||||
is_local_system_sid(tu.User.Sid)
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff `sid` is S-1-5-18 (LocalSystem).
|
||||
unsafe fn is_local_system_sid(sid: windows::Win32::Security::PSID) -> bool {
|
||||
use windows::Win32::Security::{
|
||||
GetSidIdentifierAuthority, GetSidSubAuthority, GetSidSubAuthorityCount, IsValidSid,
|
||||
};
|
||||
if !IsValidSid(sid).as_bool() {
|
||||
return false;
|
||||
}
|
||||
let auth = GetSidIdentifierAuthority(sid);
|
||||
if auth.is_null() {
|
||||
return false;
|
||||
}
|
||||
// NT Authority = {0,0,0,0,0,5}.
|
||||
let a = (*auth).Value;
|
||||
if a != [0, 0, 0, 0, 0, 5] {
|
||||
return false;
|
||||
}
|
||||
let count = *GetSidSubAuthorityCount(sid);
|
||||
if count != 1 {
|
||||
return false;
|
||||
}
|
||||
*GetSidSubAuthority(sid, 0) == 18 // SECURITY_LOCAL_SYSTEM_RID
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
//!
|
||||
//! **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`/`four_four_four` and the multi-site `perf`/`compositor`/
|
||||
//! `encoder_pref`, `render_adapter`, the vdisplay backend select — plus the plan-named
|
||||
//! `idd_depth`/`zerocopy`/`ten_bit`/`four_four_four` and the multi-site `perf`/`compositor`/
|
||||
//! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the
|
||||
//! capture/topology/encoder decision.
|
||||
//!
|
||||
@@ -36,27 +36,17 @@ use std::sync::OnceLock;
|
||||
/// 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).
|
||||
/// `PUNKTFUNK_IDD_PUSH` — IDD direct-push monitor mode (the per-session monitor + ring recreate and
|
||||
/// the discrete-render-GPU pin in [`crate::vdisplay::manager`]). IDD-push is the sole Windows capture
|
||||
/// path (DXGI Desktop Duplication and the WGC relay were removed), so this should stay on — the
|
||||
/// installer's `host.env` sets it. **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on);
|
||||
/// unset ⇒ off. NOT a bare presence flag (so an operator can turn it OFF with `=0`).
|
||||
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).
|
||||
@@ -103,14 +93,7 @@ impl HostConfig {
|
||||
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),
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
//! lets a picker show the fingerprint and pre-pin a chosen host;
|
||||
//! - `pair` — `required` or `optional`, so a client can tell up front whether it must run the PIN
|
||||
//! pairing ceremony before it can stream;
|
||||
//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises).
|
||||
//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises);
|
||||
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
|
||||
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
|
||||
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
@@ -30,7 +33,9 @@ pub struct Advert {
|
||||
}
|
||||
|
||||
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
|
||||
/// `require_pairing` tells a discovering client whether it must pair before it can stream.
|
||||
/// `require_pairing` tells a discovering client whether it must pair before it can stream;
|
||||
/// `mgmt_port` is the management API's port (`Some` when this host serves one — the client browses
|
||||
/// the library there over mTLS on the advertised IP), `None` for a host with no mgmt API.
|
||||
pub fn advertise_native(
|
||||
hostname: &str,
|
||||
ip: IpAddr,
|
||||
@@ -38,6 +43,7 @@ pub fn advertise_native(
|
||||
fingerprint: &str,
|
||||
require_pairing: bool,
|
||||
uniqueid: &str,
|
||||
mgmt_port: Option<u16>,
|
||||
) -> Result<Advert> {
|
||||
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
|
||||
let host_name = format!("{hostname}.local.");
|
||||
@@ -54,6 +60,9 @@ pub fn advertise_native(
|
||||
.into(),
|
||||
);
|
||||
props.insert("id".into(), uniqueid.to_string());
|
||||
if let Some(mgmt) = mgmt_port {
|
||||
props.insert("mgmt".into(), mgmt.to_string());
|
||||
}
|
||||
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")?;
|
||||
daemon
|
||||
|
||||
@@ -17,6 +17,10 @@ pub struct AppEntry {
|
||||
pub compositor: Option<crate::vdisplay::Compositor>,
|
||||
/// Command gamescope runs nested (gamescope entries only).
|
||||
pub cmd: Option<String>,
|
||||
/// Store-qualified library id (`steam:570`, `epic:…`) for entries surfaced from the host's game
|
||||
/// library ([`crate::library`]). When set, the launch path resolves + launches it against the
|
||||
/// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries.
|
||||
pub library_id: Option<String>,
|
||||
}
|
||||
|
||||
fn config_path() -> Option<std::path::PathBuf> {
|
||||
@@ -35,9 +39,18 @@ fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog: the user's `apps.json` if present, else defaults (Desktop, plus gamescope
|
||||
/// entries when gamescope is installed).
|
||||
/// The GameStream catalog Moonlight sees in `/applist`: the operator base ([`base_catalog`] — Desktop +
|
||||
/// apps.json) with the host's auto-detected game library ([`append_library`]) layered on top, so a
|
||||
/// Moonlight client sees the same Steam/Epic/GOG/Xbox titles the native clients do instead of just Desktop.
|
||||
pub fn catalog() -> Vec<AppEntry> {
|
||||
let mut apps = base_catalog();
|
||||
append_library(&mut apps);
|
||||
apps
|
||||
}
|
||||
|
||||
/// The operator base: the user's `apps.json` if present, else defaults (Desktop, plus gamescope
|
||||
/// entries when gamescope is installed). The installed game library is layered on by [`append_library`].
|
||||
fn base_catalog() -> Vec<AppEntry> {
|
||||
if let Some(path) = config_path() {
|
||||
if let Ok(raw) = std::fs::read_to_string(&path) {
|
||||
match serde_json::from_str::<Value>(&raw) {
|
||||
@@ -53,6 +66,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
.and_then(|c| c.as_str())
|
||||
.and_then(parse_compositor),
|
||||
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
||||
library_id: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -72,6 +86,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
title: "Desktop".into(),
|
||||
compositor: None,
|
||||
cmd: None,
|
||||
library_id: None,
|
||||
}];
|
||||
if which("gamescope") {
|
||||
if which("steam") {
|
||||
@@ -80,6 +95,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
title: "Steam".into(),
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("steam -gamepadui".into()),
|
||||
library_id: None,
|
||||
});
|
||||
}
|
||||
if which("vkcube") {
|
||||
@@ -88,23 +104,79 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
title: "vkcube (test)".into(),
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("vkcube".into()),
|
||||
library_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// The high half of the positive `i32` range — where library-derived GameStream ids live, kept clear of
|
||||
/// the small Desktop/apps.json ids so the two never collide.
|
||||
const LIBRARY_ID_BASE: u32 = 0x4000_0000;
|
||||
|
||||
/// Append the host's installed game library ([`crate::library::all_games`] — Steam/Epic/GOG/Xbox/custom)
|
||||
/// to `apps`. Each title gets a STABLE GameStream `<ID>` derived from its store-qualified library id
|
||||
/// (Moonlight caches appids, so a title keeps its id across host restarts), carries that library id so
|
||||
/// the launch path resolves it against the host's own library, and is de-duplicated (by id) against the
|
||||
/// base catalog and the other library entries. Titles with no launch recipe are skipped (un-startable).
|
||||
fn append_library(apps: &mut Vec<AppEntry>) {
|
||||
let mut used: std::collections::HashSet<u32> = apps.iter().map(|a| a.id).collect();
|
||||
for g in crate::library::all_games() {
|
||||
if g.launch.is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut id = stable_app_id(&g.id);
|
||||
// Linear-probe within the library range on the (rare) hash collision — deterministic given the
|
||||
// stable all_games() order, so a title keeps its id run to run.
|
||||
while !used.insert(id) {
|
||||
id = LIBRARY_ID_BASE | (id.wrapping_add(1) & 0x3FFF_FFFF);
|
||||
}
|
||||
apps.push(AppEntry {
|
||||
id,
|
||||
title: g.title,
|
||||
compositor: None, // auto-detect the desktop session (Windows ignores the compositor)
|
||||
cmd: None,
|
||||
library_id: Some(g.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A STABLE GameStream `<ID>` for a store-qualified library id (`steam:570`): FNV-1a-32 folded into the
|
||||
/// high half of the positive `i32` range ([`LIBRARY_ID_BASE`]). Deterministic across runs and clear of
|
||||
/// the reserved small Desktop/apps.json ids.
|
||||
fn stable_app_id(library_id: &str) -> u32 {
|
||||
let mut h: u32 = 0x811c_9dc5;
|
||||
for b in library_id.bytes() {
|
||||
h ^= b as u32;
|
||||
h = h.wrapping_mul(0x0100_0193);
|
||||
}
|
||||
LIBRARY_ID_BASE | (h & 0x3FFF_FFFF)
|
||||
}
|
||||
|
||||
pub fn by_id(id: u32) -> Option<AppEntry> {
|
||||
catalog().into_iter().find(|a| a.id == id)
|
||||
}
|
||||
|
||||
/// Render the GameStream `/applist` XML.
|
||||
/// Box-art bytes for the GameStream `/appasset` cover proxy: resolve the Moonlight appid to its catalog
|
||||
/// entry, then (for a library title) fetch its cover from the host's library. `(bytes, content-type)`,
|
||||
/// or `None` for Desktop / apps.json entries (no art) or a fetch failure. Blocking (disk + network) —
|
||||
/// call off the async runtime.
|
||||
pub fn appasset_bytes(appid: u32) -> Option<(Vec<u8>, String)> {
|
||||
let lib_id = by_id(appid)?.library_id?;
|
||||
crate::library::fetch_box_art(&lib_id)
|
||||
}
|
||||
|
||||
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
|
||||
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
|
||||
/// true, Moonlight offers its per-app HDR toggle.
|
||||
pub fn applist_xml() -> String {
|
||||
let hdr = u8::from(crate::gamestream::host_hdr_capable());
|
||||
let mut xml =
|
||||
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
||||
for app in catalog() {
|
||||
xml.push_str(&format!(
|
||||
"<App>\n<IsHdrSupported>0</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
||||
"<App>\n<IsHdrSupported>{hdr}</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
||||
xml_escape(&app.title),
|
||||
app.id
|
||||
));
|
||||
@@ -130,10 +202,46 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_catalog_has_desktop() {
|
||||
// catalog() = base (Desktop + apps.json) + the installed library; Desktop (id 1) is always present.
|
||||
let apps = catalog();
|
||||
assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_app_id_is_deterministic_and_in_library_range() {
|
||||
// Same id every run (Moonlight caches appids), distinct per title, and always in the high
|
||||
// half of the positive i32 range so it never collides with the small Desktop/apps.json ids.
|
||||
let a = stable_app_id("steam:570");
|
||||
let b = stable_app_id("steam:570");
|
||||
let c = stable_app_id("steam:271590");
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
for id in [a, c] {
|
||||
assert!(id >= LIBRARY_ID_BASE, "id {id:#x} below library base");
|
||||
assert!(id <= 0x7FFF_FFFF, "id {id:#x} not a positive i32");
|
||||
assert_ne!(id, 1, "must not collide with Desktop");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_library_dedups_against_base_ids() {
|
||||
// A base app whose id happens to fall in the library range must not be clobbered by a library
|
||||
// entry that hashes to it — append_library probes past any used id.
|
||||
let mut apps = vec![AppEntry {
|
||||
id: stable_app_id("steam:570"),
|
||||
title: "Pinned".into(),
|
||||
compositor: None,
|
||||
cmd: None,
|
||||
library_id: None,
|
||||
}];
|
||||
append_library(&mut apps);
|
||||
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
|
||||
let mut uniq = ids.clone();
|
||||
uniq.sort_unstable();
|
||||
uniq.dedup();
|
||||
assert_eq!(ids.len(), uniq.len(), "duplicate GameStream ids in catalog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applist_xml_is_wellformed_ish() {
|
||||
let xml = applist_xml();
|
||||
|
||||
@@ -66,6 +66,13 @@ pub const BTN_A: u32 = 0x1000;
|
||||
pub const BTN_B: u32 = 0x2000;
|
||||
pub const BTN_X: u32 = 0x4000;
|
||||
pub const BTN_Y: u32 = 0x8000;
|
||||
// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`):
|
||||
// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the
|
||||
// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd.
|
||||
pub const BTN_PADDLE1: u32 = 0x0001_0000;
|
||||
pub const BTN_PADDLE2: u32 = 0x0002_0000;
|
||||
pub const BTN_PADDLE3: u32 = 0x0004_0000;
|
||||
pub const BTN_PADDLE4: u32 = 0x0008_0000;
|
||||
|
||||
/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse,
|
||||
/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
|
||||
|
||||
@@ -48,13 +48,26 @@ pub const SCM_HEVC: u32 = 0x0000_0100;
|
||||
pub const SCM_HEVC_MAIN10: u32 = 0x0000_0200;
|
||||
pub const SCM_AV1_MAIN8: u32 = 0x0001_0000;
|
||||
pub const SCM_AV1_MAIN10: u32 = 0x0002_0000;
|
||||
/// What we actually encode via NVENC: H.264, HEVC Main, AV1 Main 8-bit (= 65793). The
|
||||
/// 10-bit flags are deliberately NOT advertised: Moonlight only selects Main10 profiles for
|
||||
/// HDR streaming, and our capture path is 8-bit SDR BGRx with no HDR metadata plumbing —
|
||||
/// advertising them would let clients enable an HDR mode we can't deliver. (The previous
|
||||
/// placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 + 4:4:4 and *no* AV1.)
|
||||
/// The **SDR baseline** codec mask: H.264, HEVC Main, AV1 Main 8-bit (= 65793). HEVC Main10 (HDR) is
|
||||
/// layered on top of this at runtime by `serverinfo::codec_mode_support` when — and only when — the
|
||||
/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
|
||||
/// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into
|
||||
/// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
|
||||
/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely: stock Moonlight is 4:2:0 and the Windows IDD-push
|
||||
/// capturer can't yet deliver full-chroma frames (`crate::capture::capturer_supports_444`).
|
||||
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
|
||||
|
||||
/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate
|
||||
/// for advertising [`SCM_HEVC_MAIN10`] in serverinfo and `IsHdrSupported` per app, and for honoring a
|
||||
/// client's `dynamicRangeMode` request. HDR capture+encode is **Windows-only** (the Linux host is
|
||||
/// 8-bit, blocked upstream) and behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate
|
||||
/// the native punktfunk/1 plane honors. When this is true the IDD-push capturer streams HEVC Main10 PQ
|
||||
/// whenever the desktop is HDR, and a client HDR request makes the GameStream video path proactively
|
||||
/// enable advanced color on the per-session virtual display so PQ flows even from an SDR desktop.
|
||||
pub fn host_hdr_capable() -> bool {
|
||||
cfg!(target_os = "windows") && crate::config::config().ten_bit
|
||||
}
|
||||
|
||||
/// Stable host identity + advertised capabilities, shared across control-plane handlers.
|
||||
pub struct Host {
|
||||
pub hostname: String,
|
||||
@@ -225,7 +238,7 @@ pub fn serve(
|
||||
tokio::try_join!(
|
||||
nvhttp::run(state.clone()),
|
||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, np, stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)?;
|
||||
} else {
|
||||
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
|
||||
@@ -236,7 +249,7 @@ pub fn serve(
|
||||
);
|
||||
tokio::try_join!(
|
||||
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, np, stats.clone()),
|
||||
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -13,8 +13,8 @@ use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_POR
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Extension, Router,
|
||||
};
|
||||
@@ -64,6 +64,7 @@ fn router(state: Arc<AppState>, https: bool) -> Router {
|
||||
.route("/serverinfo", get(h_serverinfo))
|
||||
.route("/pair", get(h_pair))
|
||||
.route("/applist", get(h_applist))
|
||||
.route("/appasset", get(h_appasset))
|
||||
.route("/launch", get(h_launch))
|
||||
.route("/resume", get(h_resume))
|
||||
.route("/cancel", get(h_cancel))
|
||||
@@ -94,10 +95,32 @@ async fn h_applist(
|
||||
tracing::warn!("applist rejected — client is not paired");
|
||||
return xml(error_xml());
|
||||
}
|
||||
// One app for now: the headless desktop (the wlroots virtual output).
|
||||
xml(super::apps::applist_xml())
|
||||
}
|
||||
|
||||
/// Box-art cover proxy (`/appasset?appid=N&AssetType=2&AssetIdx=0`). Moonlight fetches per-app covers
|
||||
/// from the HOST, so we resolve the appid to its library title and proxy the cover image bytes (Steam/
|
||||
/// Epic CDN, etc.). 404 for Desktop / apps.json entries (no art) or any fetch failure — Moonlight then
|
||||
/// shows its title-only placeholder. Paired clients only (same gate as `/applist`). The resolve+fetch is
|
||||
/// blocking (disk + network), so it runs on a blocking thread off the async runtime.
|
||||
async fn h_appasset(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
if !peer_is_paired(&peer, &st) {
|
||||
tracing::warn!("appasset rejected — client is not paired");
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let Some(appid) = q.get("appid").and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
};
|
||||
match tokio::task::spawn_blocking(move || super::apps::appasset_bytes(appid)).await {
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_launch(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
|
||||
@@ -101,6 +101,10 @@ struct Session {
|
||||
server_challenge: [u8; 16],
|
||||
/// The client's phase-3 hash, recomputed + checked in phase 4.
|
||||
client_hash: Vec<u8>,
|
||||
/// Set once phase 3 has produced the RSA-signed serversecret. A repeated phase 3 is refused so a
|
||||
/// peer past phase 1 can't loop phase2/phase3 to harvest many signing-time samples (a passive
|
||||
/// timing-oracle amplifier vs. the rsa-crate Marvin side-channel; see `.cargo/audit.toml`).
|
||||
responded: bool,
|
||||
}
|
||||
|
||||
pub struct Pairing {
|
||||
@@ -155,6 +159,7 @@ impl Pairing {
|
||||
serversecret: [0; 16],
|
||||
server_challenge: [0; 16],
|
||||
client_hash: Vec::new(),
|
||||
responded: false,
|
||||
},
|
||||
);
|
||||
tracing::info!(
|
||||
@@ -216,6 +221,14 @@ impl Pairing {
|
||||
bail!("short challenge response");
|
||||
}
|
||||
s.client_hash = client_hash[..32].to_vec();
|
||||
// Sign the serversecret exactly ONCE per ceremony: refuse a repeated phase 3 so a peer that
|
||||
// cleared phase 1 (operator PIN) can't replay it to collect many RSA signing-time samples
|
||||
// (timing-oracle amplifier vs. RUSTSEC-2023-0071; see `.cargo/audit.toml`). A legit client
|
||||
// signs once. The session stays for phase 4 (the cert-pin step) but won't re-sign.
|
||||
if s.responded {
|
||||
bail!("serverchallengeresp already answered for this pairing session");
|
||||
}
|
||||
s.responded = true;
|
||||
let sig: Signature = id.signing_key.sign(&s.serversecret);
|
||||
let mut secret = Vec::with_capacity(16 + 256);
|
||||
secret.extend_from_slice(&s.serversecret);
|
||||
|
||||
@@ -350,19 +350,34 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
||||
let fps = parse_u("x-nv-video[0].maxFPS")
|
||||
.filter(|&f| f > 0)
|
||||
.unwrap_or(60);
|
||||
let bitrate_kbps = parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").unwrap_or(20_000);
|
||||
// Bitrate: Moonlight caps the legacy `x-nv-vqos[0].bw.*` fields at 100 Mbps for old-GFE
|
||||
// compatibility and carries the user's REAL (uncapped) configured bitrate in the moonlight-specific
|
||||
// `x-ml-video.configuredBitrateKbps`. Read that first — exactly like Sunshine — so a 500 Mbps client
|
||||
// setting isn't silently floored to 100. Fall back to the legacy max for clients that don't send it,
|
||||
// then a conservative default; clamp to a sane ceiling (the RTSP ANNOUNCE is attacker-controlled).
|
||||
const MAX_BITRATE_KBPS: u32 = 1_000_000; // 1 Gbps — well above Moonlight's 500 Mbps slider
|
||||
let bitrate_kbps = parse_u("x-ml-video.configuredBitrateKbps")
|
||||
.filter(|&b| b > 0)
|
||||
.or_else(|| parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").filter(|&b| b > 0))
|
||||
.unwrap_or(20_000)
|
||||
.min(MAX_BITRATE_KBPS);
|
||||
// Client codec choice (moonlight-common-c SdpGenerator.c): 0=H264, 1=HEVC, 2=AV1.
|
||||
let codec = match map.get("x-nv-vqos[0].bitStreamFormat").map(|s| s.trim()) {
|
||||
Some("1") => Codec::H265,
|
||||
Some("2") => Codec::Av1,
|
||||
_ => Codec::H264,
|
||||
};
|
||||
// 10-bit/HDR request flag. We never advertise the Main10 SCM bits, so a compliant
|
||||
// client can't ask — if one does anyway, stream 8-bit SDR rather than failing.
|
||||
if parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0 {
|
||||
// 10-bit/HDR request (Moonlight sets `dynamicRangeMode != 0` only when it both saw our Main10 SCM
|
||||
// bit AND the user enabled HDR). Honor it only when the host can actually deliver Main10 (Windows +
|
||||
// PUNKTFUNK_10BIT, `host_hdr_capable`); when honored, the video path proactively enables advanced
|
||||
// color on the virtual display so a PQ stream flows even from an SDR desktop. A request we can't
|
||||
// honor degrades to 8-bit SDR (and a desktop that is ALREADY HDR still streams PQ regardless, since
|
||||
// the IDD-push capturer follows the display).
|
||||
let hdr_requested = parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0;
|
||||
let hdr = hdr_requested && crate::gamestream::host_hdr_capable();
|
||||
if hdr_requested && !hdr {
|
||||
tracing::warn!(
|
||||
"client requested HDR/10-bit (dynamicRangeMode != 0) — not advertised/supported, \
|
||||
streaming 8-bit SDR"
|
||||
"client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR"
|
||||
);
|
||||
}
|
||||
// Parity floor the client asks for (protects small frames); clamp to a sane max.
|
||||
@@ -377,6 +392,7 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
||||
bitrate_kbps,
|
||||
codec,
|
||||
min_fec,
|
||||
hdr,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -490,6 +506,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bitrate precedence: the moonlight-specific `x-ml-video.configuredBitrateKbps` (the user's real,
|
||||
/// uncapped setting) wins over the legacy `x-nv-vqos[0].bw.maximumBitrateKbps` (which Moonlight floors
|
||||
/// at 100 Mbps for old-GFE compat). Without this a 500 Mbps client streamed at 100.
|
||||
#[test]
|
||||
fn announce_prefers_configured_bitrate() {
|
||||
// Real Moonlight shape: legacy max floored at 100 Mbps, configured carrying the true 500 Mbps.
|
||||
let map = announce(&[
|
||||
("x-nv-vqos[0].bw.maximumBitrateKbps", "100000"),
|
||||
("x-ml-video.configuredBitrateKbps", "500000"),
|
||||
]);
|
||||
assert_eq!(stream_config(&map).unwrap().bitrate_kbps, 500_000);
|
||||
// No configured field (older client) → fall back to the legacy max (the base announce's 40 Mbps).
|
||||
assert_eq!(stream_config(&announce(&[])).unwrap().bitrate_kbps, 40_000);
|
||||
// A zero configured value is ignored (falls back), and an absurd value is clamped to the ceiling.
|
||||
let zero = announce(&[("x-ml-video.configuredBitrateKbps", "0")]);
|
||||
assert_eq!(stream_config(&zero).unwrap().bitrate_kbps, 40_000);
|
||||
let huge = announce(&[("x-ml-video.configuredBitrateKbps", "9000000")]);
|
||||
assert_eq!(stream_config(&huge).unwrap().bitrate_kbps, 1_000_000);
|
||||
}
|
||||
|
||||
/// Missing required video keys → no config (the PLAY handler then refuses to stream).
|
||||
#[test]
|
||||
fn announce_missing_required_keys() {
|
||||
|
||||
@@ -43,11 +43,33 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// The `<ServerCodecModeSupport>` mask to advertise. On the VAAPI (AMD/Intel) backend it reflects
|
||||
/// what the GPU can ACTUALLY encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a
|
||||
/// Moonlight client never negotiates a codec the encoder can't open. NVENC and Windows keep the
|
||||
/// Moonlight-validated static superset.
|
||||
/// The `<ServerCodecModeSupport>` mask to advertise: the SDR baseline ([`base_codec_mode_support`]) plus
|
||||
/// the HEVC Main10 (HDR) bit when the host can actually deliver HDR ([`apply_hdr`] /
|
||||
/// [`crate::gamestream::host_hdr_capable`]). Without the Main10 bit Moonlight never offers its HDR
|
||||
/// toggle; with it, enabling HDR client-side negotiates Main10 and the IDD-push path streams BT.2020 PQ.
|
||||
fn codec_mode_support() -> u32 {
|
||||
apply_hdr(
|
||||
base_codec_mode_support(),
|
||||
crate::gamestream::host_hdr_capable(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Add the HEVC Main10 (HDR) bit to `base` when `hdr` and HEVC is advertised — pure so the
|
||||
/// HDR-layering is unit-testable without a GPU. (HDR streaming uses HEVC Main10; AV1 Main10 is left
|
||||
/// off until the GameStream AV1 path is live-confirmed.)
|
||||
fn apply_hdr(base: u32, hdr: bool) -> u32 {
|
||||
if hdr && base & super::SCM_HEVC != 0 {
|
||||
base | super::SCM_HEVC_MAIN10
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/// The **SDR baseline** mask. On the VAAPI (AMD/Intel) backend it reflects what the GPU can ACTUALLY
|
||||
/// encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a Moonlight client never
|
||||
/// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the
|
||||
/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`].
|
||||
fn base_codec_mode_support() -> u32 {
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::encode::linux_zero_copy_is_vaapi() {
|
||||
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
|
||||
@@ -108,6 +130,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_hdr_adds_main10_only_when_capable_and_hevc() {
|
||||
// HDR-capable + HEVC advertised → Main10 added.
|
||||
assert_eq!(
|
||||
apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, true),
|
||||
SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8 | SCM_HEVC_MAIN10
|
||||
);
|
||||
// Not HDR-capable → baseline unchanged (no HDR claim).
|
||||
assert_eq!(
|
||||
apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, false),
|
||||
SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8
|
||||
);
|
||||
// HDR-capable but a GPU with no HEVC at all → no Main10 (you can't do Main10 without HEVC).
|
||||
assert_eq!(apply_hdr(SCM_H264, true), SCM_H264);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serverinfo_xml_carries_codec_mask() {
|
||||
let host = Host {
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct StreamConfig {
|
||||
pub codec: Codec,
|
||||
/// Client's `x-nv-vqos[0].fec.minRequiredFecPackets` — parity floor per FEC block.
|
||||
pub min_fec: u8,
|
||||
/// Client requested HDR (`dynamicRangeMode != 0`) AND the host can deliver it ([`host_hdr_capable`]).
|
||||
/// Drives the capturer's proactive advanced-color enable; the encoder picks Main10 from the captured
|
||||
/// (P010) frame format. Always `false` on a non-HDR host, so the SDR path is unchanged.
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
||||
@@ -137,7 +141,15 @@ fn run(
|
||||
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
if launch_here {
|
||||
if let Some(cmd) = app
|
||||
// A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its
|
||||
// store-qualified id — resolve + launch it against the host's OWN library (the client can
|
||||
// only pick an existing title, never inject a command). An apps.json entry instead carries
|
||||
// an operator-typed `cmd`. Library id wins when both are set.
|
||||
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
|
||||
if let Err(e) = crate::library::launch_gamestream_library(lib_id) {
|
||||
tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title");
|
||||
}
|
||||
} else if let Some(cmd) = app
|
||||
.and_then(|a| a.cmd.as_deref())
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
{
|
||||
@@ -213,14 +225,26 @@ fn open_gs_virtual_source(
|
||||
let compositor = if let Some(c) = app.and_then(|a| a.compositor) {
|
||||
c
|
||||
} else {
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
let c = crate::vdisplay::compositor_for_kind(active.kind)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(crate::vdisplay::detect)
|
||||
.context("detect compositor")?;
|
||||
crate::vdisplay::apply_input_env(c);
|
||||
c
|
||||
// Windows has a single virtual-display backend (pf-vdisplay); `vdisplay::open` ignores the
|
||||
// compositor arg there, so short-circuit the Linux session-detection state machine with a
|
||||
// placeholder — mirrors `punktfunk1::resolve_compositor`. Without this, the Linux `detect()`
|
||||
// below bails on Windows ("could not detect compositor … XDG_CURRENT_DESKTOP=''"), which
|
||||
// killed the GameStream video thread → black screen (the native plane was already guarded).
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
crate::vdisplay::Compositor::Kwin
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
let c = crate::vdisplay::compositor_for_kind(active.kind)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(crate::vdisplay::detect)
|
||||
.context("detect compositor")?;
|
||||
crate::vdisplay::apply_input_env(c);
|
||||
c
|
||||
}
|
||||
};
|
||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
||||
@@ -233,11 +257,13 @@ fn open_gs_virtual_source(
|
||||
refresh_hz: cfg.fps,
|
||||
})
|
||||
.context("create virtual output at client resolution")?;
|
||||
// want_hdr=false: GameStream HDR is not negotiated into StreamConfig here (the default WGC backend
|
||||
// still auto-detects HDR from the output colorspace; only the opt-in IDD-push path streams SDR).
|
||||
// HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
|
||||
// Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10
|
||||
// PQ stream flows even from an SDR desktop; an already-HDR desktop streams PQ regardless (the
|
||||
// capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there).
|
||||
let capturer = capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false),
|
||||
capture::OutputFormat::resolve(cfg.hdr),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
@@ -245,6 +271,19 @@ fn open_gs_virtual_source(
|
||||
Ok((capturer, compositor))
|
||||
}
|
||||
|
||||
/// The encoder bit depth implied by the captured frame's pixel format: a 10-bit (HDR) source — the
|
||||
/// Windows IDD-push capturer's `P010`/`Rgb10a2` when the desktop is HDR — opens NVENC as HEVC Main10
|
||||
/// (BT.2020 PQ); everything else is 8-bit. The encoder backends already key the real profile off the
|
||||
/// `format`, so this just keeps the `bit_depth` argument honest (the old hard-coded `8` mislabeled an
|
||||
/// HDR stream that the format had already promoted to 10-bit).
|
||||
fn gs_bit_depth(format: crate::capture::PixelFormat) -> u8 {
|
||||
use crate::capture::PixelFormat;
|
||||
match format {
|
||||
PixelFormat::P010 | PixelFormat::Rgb10a2 => 10,
|
||||
_ => 8,
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame's packets, handed from the encode thread to the send thread.
|
||||
type PacketBatch = Vec<Vec<u8>>;
|
||||
|
||||
@@ -430,9 +469,10 @@ fn stream_body(
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
8, // GameStream/Moonlight path: 8-bit (its own codec negotiation)
|
||||
// 8-bit SDR, or 10-bit when the captured frame is HDR (P010) — see `gs_bit_depth`.
|
||||
gs_bit_depth(frame.format),
|
||||
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
||||
// protocol has no chroma negotiation. 4:4:4 is punktfunk/1-native only.
|
||||
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
||||
encode::ChromaFormat::Yuv420,
|
||||
)
|
||||
.context("open video encoder for stream")?;
|
||||
@@ -562,7 +602,7 @@ fn stream_body(
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
8,
|
||||
gs_bit_depth(frame.format),
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||
)
|
||||
.context("reopen encoder after rebuild")?;
|
||||
|
||||
@@ -491,6 +491,31 @@ pub mod gamepad;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/gamepad_raii.rs"]
|
||||
mod gamepad_raii;
|
||||
/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_controller.rs"]
|
||||
pub mod steam_controller;
|
||||
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
||||
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
|
||||
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_gadget.rs"]
|
||||
pub mod steam_gadget;
|
||||
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck
|
||||
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/steam_proto.rs"]
|
||||
pub mod steam_proto;
|
||||
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/steam_remap.rs"]
|
||||
pub mod steam_remap;
|
||||
/// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean,
|
||||
/// Steam-Input-promotable virtual-Deck transport on non-SteamOS hosts (Bazzite/generic), where
|
||||
/// `dummy_hcd`/`raw_gadget` aren't built. In-tree + signed; no module build, no MOK.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_usbip.rs"]
|
||||
pub mod steam_usbip;
|
||||
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub mod gamepad {
|
||||
|
||||
@@ -182,6 +182,9 @@ pub struct DualSenseManager {
|
||||
last_write: Vec<Instant>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
|
||||
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||
remap: crate::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DualSenseManager {
|
||||
@@ -198,6 +201,7 @@ impl DualSenseManager {
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,8 +233,12 @@ impl DualSenseManager {
|
||||
// Merge buttons/sticks/triggers from the frame, preserving touch + motion (those
|
||||
// come on the rich-input plane and must survive a button-only frame).
|
||||
let prev = self.state[idx];
|
||||
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
||||
// configured policy (default drop) so they aren't silently lost.
|
||||
let buttons =
|
||||
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
@@ -252,7 +260,9 @@ impl DualSenseManager {
|
||||
/// arrived first); they're dropped if the pad isn't present.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
@@ -280,6 +290,26 @@ impl DualSenseManager {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
@@ -367,6 +367,9 @@ pub struct DualShock4Manager {
|
||||
last_write: Vec<Instant>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
|
||||
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||
remap: crate::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DualShock4Manager {
|
||||
@@ -384,6 +387,7 @@ impl DualShock4Manager {
|
||||
last_led: vec![None; MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,8 +420,12 @@ impl DualShock4Manager {
|
||||
// Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the
|
||||
// rich-input plane and must survive a button-only frame).
|
||||
let prev = self.state[idx];
|
||||
// Steam back grips have no DS4 slot — fold them onto standard buttons per the
|
||||
// configured policy (default drop) so they aren't silently lost.
|
||||
let buttons =
|
||||
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
@@ -439,7 +447,9 @@ impl DualShock4Manager {
|
||||
/// pad isn't present.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
@@ -466,6 +476,26 @@ impl DualShock4Manager {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
@@ -69,9 +69,16 @@ const BTN_START: u16 = 0x13b;
|
||||
const BTN_MODE: u16 = 0x13c;
|
||||
const BTN_THUMBL: u16 = 0x13d;
|
||||
const BTN_THUMBR: u16 = 0x13e;
|
||||
// Xbox-Elite paddle codes (the xpad convention SDL / Steam Input recognize). A client's back grips —
|
||||
// and the GameStream `buttonFlags2` paddle bits, which were silently dropped before — land here, so
|
||||
// the virtual X-Box pad exposes paddles like an Elite controller. PADDLE1/2/3/4 = R4/L4/R5/L5.
|
||||
const BTN_TRIGGER_HAPPY5: u16 = 0x2c4;
|
||||
const BTN_TRIGGER_HAPPY6: u16 = 0x2c5;
|
||||
const BTN_TRIGGER_HAPPY7: u16 = 0x2c6;
|
||||
const BTN_TRIGGER_HAPPY8: u16 = 0x2c7;
|
||||
|
||||
/// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead.
|
||||
const BUTTON_MAP: [(u32, u16); 11] = [
|
||||
const BUTTON_MAP: [(u32, u16); 15] = [
|
||||
(gamepad::BTN_A, BTN_SOUTH),
|
||||
(gamepad::BTN_B, BTN_EAST),
|
||||
(gamepad::BTN_X, BTN_NORTH),
|
||||
@@ -83,6 +90,10 @@ const BUTTON_MAP: [(u32, u16); 11] = [
|
||||
(gamepad::BTN_GUIDE, BTN_MODE),
|
||||
(gamepad::BTN_LS_CLK, BTN_THUMBL),
|
||||
(gamepad::BTN_RS_CLK, BTN_THUMBR),
|
||||
(gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5),
|
||||
(gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6),
|
||||
(gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7),
|
||||
(gamepad::BTN_PADDLE4, BTN_TRIGGER_HAPPY8),
|
||||
];
|
||||
|
||||
/// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off
|
||||
|
||||
@@ -7,9 +7,14 @@
|
||||
//! which the libei/portal path cannot. We connect as an ordinary Wayland client on the KWin session's
|
||||
//! `$WAYLAND_DISPLAY` and translate events into fake-input requests; keyboard keys are raw Linux
|
||||
//! evdev codes that KWin resolves through the session's own keymap (no keymap upload, unlike the wlr
|
||||
//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space — which
|
||||
//! on a headless box (single per-session virtual output at the origin, scale 1) equals the streamed
|
||||
//! output's pixels.
|
||||
//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space.
|
||||
//!
|
||||
//! Global compositor space is *logical* pixels (post display-scaling), which only equals the streamed
|
||||
//! output's physical pixels at scale 1. Under a fractional/integer scale the logical edge sits at
|
||||
//! `physical / scale`, so feeding the raw streamed pixel coordinate lands the cursor `scale×` too far
|
||||
//! toward the bottom-right (top-left stays put). We therefore track each output's logical geometry
|
||||
//! (position + size) via `xdg-output` and map the normalized client position into the matching
|
||||
//! output's logical rectangle — the same shape the libei backend uses with its EI region.
|
||||
|
||||
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
@@ -18,8 +23,14 @@
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::input::InputKind;
|
||||
use std::time::{Duration, Instant};
|
||||
use wayland_client::protocol::wl_output::{self, WlOutput};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum};
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::{
|
||||
zxdg_output_manager_v1::ZxdgOutputManagerV1,
|
||||
zxdg_output_v1::{self, ZxdgOutputV1},
|
||||
};
|
||||
|
||||
// Generate the client bindings for the vendored protocol XML inline (no build.rs), exactly like the
|
||||
// KWin virtual-output backend. Path is relative to CARGO_MANIFEST_DIR.
|
||||
@@ -48,10 +59,39 @@ const AXIS_HORIZONTAL: u32 = 1;
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// One tracked output: its physical mode (to match the streamed resolution) and its logical geometry
|
||||
/// (the global-compositor-space rectangle absolute coordinates are addressed in). `logical_w == 0`
|
||||
/// means xdg-output hasn't reported its size yet.
|
||||
struct OutputTrack {
|
||||
/// Registry global id — also the dispatch user-data, so events route back to this entry.
|
||||
name: u32,
|
||||
wl_output: WlOutput,
|
||||
xdg_output: Option<ZxdgOutputV1>,
|
||||
/// Physical pixel mode from `wl_output.mode` (the `current` mode); matched against the streamed WxH.
|
||||
mode_w: i32,
|
||||
mode_h: i32,
|
||||
/// Logical (post-scale) geometry from `xdg-output`.
|
||||
logical_x: i32,
|
||||
logical_y: i32,
|
||||
logical_w: i32,
|
||||
logical_h: i32,
|
||||
}
|
||||
|
||||
/// Registry-bound globals (the Wayland dispatch state).
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
fake: Option<FakeInput>,
|
||||
xdg_mgr: Option<ZxdgOutputManagerV1>,
|
||||
outputs: Vec<OutputTrack>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Create the `xdg_output` for a tracked output once both it and the manager exist.
|
||||
fn ensure_xdg_output(o: &mut OutputTrack, mgr: &ZxdgOutputManagerV1, qh: &QueueHandle<State>) {
|
||||
if o.xdg_output.is_none() {
|
||||
o.xdg_output = Some(mgr.get_xdg_output(&o.wl_output, qh, o.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
@@ -63,15 +103,57 @@ impl Dispatch<WlRegistry, ()> for State {
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
if interface == "org_kde_kwin_fake_input" {
|
||||
state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => match interface.as_str() {
|
||||
"org_kde_kwin_fake_input" => {
|
||||
state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
|
||||
}
|
||||
"wl_output" => {
|
||||
// v1 carries `mode` (all we need); bind no higher than the proxy's max (4).
|
||||
let wl_output: WlOutput = registry.bind(name, version.min(4), qh, name);
|
||||
let mut o = OutputTrack {
|
||||
name,
|
||||
wl_output,
|
||||
xdg_output: None,
|
||||
mode_w: 0,
|
||||
mode_h: 0,
|
||||
logical_x: 0,
|
||||
logical_y: 0,
|
||||
logical_w: 0,
|
||||
logical_h: 0,
|
||||
};
|
||||
if let Some(mgr) = state.xdg_mgr.clone() {
|
||||
State::ensure_xdg_output(&mut o, &mgr, qh);
|
||||
}
|
||||
state.outputs.push(o);
|
||||
}
|
||||
"zxdg_output_manager_v1" => {
|
||||
let mgr: ZxdgOutputManagerV1 = registry.bind(name, version.min(3), qh, ());
|
||||
// Outputs bound before the manager have no xdg_output yet — create them now.
|
||||
for o in state.outputs.iter_mut() {
|
||||
State::ensure_xdg_output(o, &mgr, qh);
|
||||
}
|
||||
state.xdg_mgr = Some(mgr);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.retain(|o| {
|
||||
if o.name == name {
|
||||
if let Some(x) = &o.xdg_output {
|
||||
x.destroy();
|
||||
}
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,13 +171,86 @@ impl Dispatch<FakeInput, ()> for State {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
name: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// Only the *current* mode matters — a real monitor also advertises its other supported modes.
|
||||
if let wl_output::Event::Mode {
|
||||
flags: WEnum::Value(flags),
|
||||
width,
|
||||
height,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if flags.contains(wl_output::Mode::Current) {
|
||||
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
|
||||
o.mode_w = width;
|
||||
o.mode_h = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgOutputV1, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ZxdgOutputV1,
|
||||
event: zxdg_output_v1::Event,
|
||||
name: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
|
||||
match event {
|
||||
zxdg_output_v1::Event::LogicalPosition { x, y } => {
|
||||
o.logical_x = x;
|
||||
o.logical_y = y;
|
||||
}
|
||||
zxdg_output_v1::Event::LogicalSize { width, height } => {
|
||||
o.logical_w = width;
|
||||
o.logical_h = height;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manager has no events.
|
||||
impl Dispatch<ZxdgOutputManagerV1, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &ZxdgOutputManagerV1,
|
||||
_: <ZxdgOutputManagerV1 as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KwinFakeInjector {
|
||||
conn: Connection,
|
||||
queue: EventQueue<State>,
|
||||
state: State,
|
||||
fake: FakeInput,
|
||||
/// When output geometry was last re-read; throttles the per-event roundtrip (see `refresh_geometry`).
|
||||
last_refresh: Option<Instant>,
|
||||
}
|
||||
|
||||
/// How often the fake_input backend re-reads output geometry from the compositor. Output add/remove
|
||||
/// (a new session's virtual output) and live scale/resolution changes are infrequent, so a lazy
|
||||
/// poll on the injector's own thread is plenty and adds at most one local-socket roundtrip twice a
|
||||
/// second — versus a blocking roundtrip on every single mouse-move event.
|
||||
const GEO_REFRESH: Duration = Duration::from_millis(500);
|
||||
|
||||
impl KwinFakeInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
let conn = Connection::connect_to_env()
|
||||
@@ -122,13 +277,77 @@ impl KwinFakeInjector {
|
||||
.context("fake_input authenticate roundtrip")?;
|
||||
conn.flush().ok();
|
||||
|
||||
tracing::info!("KWin fake_input ready (headless keyboard/mouse/touch — no portal)");
|
||||
Ok(Self {
|
||||
// Settle output geometry (wl_output + xdg-output were bound during the registry roundtrip
|
||||
// above; their logical_size arrives on a follow-up roundtrip). Best-effort — falls back to
|
||||
// scale-1 mapping if xdg-output is absent.
|
||||
let mut injector = Self {
|
||||
conn,
|
||||
queue,
|
||||
state,
|
||||
fake,
|
||||
})
|
||||
last_refresh: None,
|
||||
};
|
||||
injector.refresh_geometry();
|
||||
tracing::info!(
|
||||
outputs = injector.state.outputs.len(),
|
||||
"KWin fake_input ready (headless keyboard/mouse/touch — no portal)"
|
||||
);
|
||||
Ok(injector)
|
||||
}
|
||||
|
||||
/// Re-read output geometry, throttled to [`GEO_REFRESH`]. A `roundtrip` both flushes any pending
|
||||
/// `get_xdg_output` requests and reads the geometry events back. A wl_output that *appeared* this
|
||||
/// round only gets its xdg_output created mid-dispatch, so its `logical_size` lands on a later
|
||||
/// roundtrip — keep going (bounded) until every output is settled.
|
||||
fn refresh_geometry(&mut self) {
|
||||
let now = Instant::now();
|
||||
if let Some(t) = self.last_refresh {
|
||||
if now.duration_since(t) < GEO_REFRESH {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.last_refresh = Some(now);
|
||||
for _ in 0..3 {
|
||||
if self.queue.roundtrip(&mut self.state).is_err() {
|
||||
return;
|
||||
}
|
||||
let pending =
|
||||
self.state.xdg_mgr.is_some() && self.state.outputs.iter().any(|o| o.logical_w == 0);
|
||||
if !pending {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the logical (global-compositor-space) rectangle to map a normalized client position
|
||||
/// into. Prefer the output whose physical mode matches the streamed `phys_w`×`phys_h` (the
|
||||
/// per-session virtual output); fall back to the sole output, then — if xdg-output is unavailable
|
||||
/// — to the streamed pixels at the origin (the pre-scaling behavior, correct at scale 1).
|
||||
fn logical_target(&self, phys_w: i32, phys_h: i32) -> (f64, f64, f64, f64) {
|
||||
let usable = || {
|
||||
self.state
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|o| o.logical_w > 0 && o.logical_h > 0)
|
||||
};
|
||||
let chosen = usable()
|
||||
.find(|o| o.mode_w == phys_w && o.mode_h == phys_h)
|
||||
.or_else(|| {
|
||||
let mut it = usable();
|
||||
match (it.next(), it.next()) {
|
||||
(Some(only), None) => Some(only),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
match chosen {
|
||||
Some(o) => (
|
||||
o.logical_x as f64,
|
||||
o.logical_y as f64,
|
||||
o.logical_w as f64,
|
||||
o.logical_h as f64,
|
||||
),
|
||||
None => (0.0, 0.0, phys_w as f64, phys_h as f64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,12 +358,17 @@ impl InputInjector for KwinFakeInjector {
|
||||
self.fake.pointer_motion(event.x as f64, event.y as f64);
|
||||
}
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||||
let h = (event.flags & 0xffff) as i32;
|
||||
if w > 0 && h > 0 {
|
||||
let x = event.x.clamp(0, w as i32) as f64;
|
||||
let y = event.y.clamp(0, h as i32) as f64;
|
||||
self.fake.pointer_motion_absolute(x, y);
|
||||
self.refresh_geometry();
|
||||
let (lx, ly, lw, lh) = self.logical_target(w, h);
|
||||
// Normalize in the streamed (physical) pixel space, then place inside the output's
|
||||
// logical rectangle — so display scaling no longer offsets the cursor.
|
||||
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
|
||||
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
|
||||
self.fake
|
||||
.pointer_motion_absolute(lx + nx * lw, ly + ny * lh);
|
||||
}
|
||||
}
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
@@ -179,11 +403,15 @@ impl InputInjector for KwinFakeInjector {
|
||||
// Touch: id = event.code, coords in the client surface w×h packed into flags (same
|
||||
// absolute mapping as MouseMoveAbs). Each event is its own frame.
|
||||
InputKind::TouchDown | InputKind::TouchMove => {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||||
let h = (event.flags & 0xffff) as i32;
|
||||
if w > 0 && h > 0 {
|
||||
let x = event.x.clamp(0, w as i32) as f64;
|
||||
let y = event.y.clamp(0, h as i32) as f64;
|
||||
self.refresh_geometry();
|
||||
let (lx, ly, lw, lh) = self.logical_target(w, h);
|
||||
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
|
||||
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
|
||||
let x = lx + nx * lw;
|
||||
let y = ly + ny * lh;
|
||||
if event.kind == InputKind::TouchDown {
|
||||
self.fake.touch_down(event.code, x, y);
|
||||
} else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user