Compare commits
59 Commits
26c6c939a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ed54f22997 | |||
| 031ee86ed5 | |||
| 7591425f6f | |||
| d1d2ca293d | |||
| 705a8fa94e | |||
| 4ba63b7da6 | |||
| bee1f0416d | |||
| 54d9246ca7 | |||
| 91bb955d0c | |||
| 36259b264f | |||
| 6f903f79bc | |||
| 3532e35b75 | |||
| 6b846913f5 |
+25
-8
@@ -5,16 +5,33 @@
|
|||||||
# means the audit job stops flagging it, so the reasoning must hold up.
|
# means the audit job stops flagging it, so the reasoning must hold up.
|
||||||
#
|
#
|
||||||
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
||||||
# `unmaintained` warnings (audiopus_sys / paste / rustls-pemfile). Those are left visible on purpose
|
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
|
||||||
# so we keep getting the maintenance signal — they do not fail CI.
|
# their latest published version with no successor, so there's nothing to bump — left visible on
|
||||||
|
# purpose so we keep getting the maintenance signal; they do not fail CI. (rustls-pemfile was dropped
|
||||||
|
# 2026-06-29 by removing axum-server's unused tls-rustls feature + moving our own PEM parsing to
|
||||||
|
# rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11 bump.)
|
||||||
|
|
||||||
[advisories]
|
[advisories]
|
||||||
ignore = [
|
ignore = [
|
||||||
# rsa "Marvin Attack" — a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle).
|
# rsa "Marvin Attack" (RUSTSEC-2023-0071): a timing side-channel in the rsa crate's variable-time
|
||||||
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa
|
# modular exponentiation of the SECRET exponent. IMPORTANT — this affects the RSA private-key op in
|
||||||
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5
|
# general, INCLUDING signing (m^d mod n), which the host DOES perform (gamestream/pairing.rs
|
||||||
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey /
|
# `signing_key.sign(&serversecret)`). It is NOT, as an earlier version of this note wrongly claimed,
|
||||||
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets.
|
# limited to decryption — so "the vulnerable path isn't exercised" is false; signing exercises it.
|
||||||
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt.
|
# 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",
|
"RUSTSEC-2023-0071",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -207,10 +207,20 @@ jobs:
|
|||||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# Separate archive from the Developer ID one above: App Store needs a profile-signed
|
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||||
# archive (manual signing), not the unsigned-then-codesign DMG path. Same App-Manager
|
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||||
# ASC-key constraint as iOS/tvOS — MANUAL signing, NOT -allowProvisioningUpdates
|
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||||
# (cloud signing the key can't do). Quit Xcode so it can't prune the dropped profile.
|
# 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
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk macOS App Store Distribution"
|
PROFILE="Punktfunk macOS App Store Distribution"
|
||||||
@@ -218,11 +228,10 @@ jobs:
|
|||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||||
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGN_STYLE=Manual \
|
CODE_SIGN_STYLE=Automatic \
|
||||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
|
||||||
PROVISIONING_PROFILE_SPECIFIER="$PROFILE"
|
|
||||||
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!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.
|
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# MANUAL App Store signing: the local (valid) Apple Distribution identity + the App
|
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||||
# Store provisioning profile. NOT -allowProvisioningUpdates — with an App-Manager-role
|
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||||
# ASC key that forces Xcode's CLOUD-managed signing, which the role can't do ("Cloud
|
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||||
# signing permission error"). The profile must be installed on the runner under
|
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||||
# ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ (install it once with
|
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||||
# Xcode.app quit, or it prunes the manually-dropped distribution profile).
|
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
|
||||||
# A running Xcode.app prunes unrecognized profiles from that dir — quit it so the App
|
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
||||||
# Store profile survives this build; headless xcodebuild doesn't need the GUI app.
|
# 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
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk iOS App Store Distribution"
|
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 \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-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
|
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!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).
|
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
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
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
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 \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||||
-destination 'generic/platform=tvOS' \
|
-destination 'generic/platform=tvOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-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
|
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
|||||||
@@ -24,8 +24,9 @@
|
|||||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||||
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
||||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
# .def with llvm-dlltool (no GPU/SDK at build time).
|
||||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN gpl-shared
|
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
||||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||||
|
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||||
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
||||||
name: windows-host
|
name: windows-host
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ jobs:
|
|||||||
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
||||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
# FFMPEG_DIR: the same BtbN gpl-shared x64 tree the Windows CLIENT links against (provisioned
|
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||||
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
|
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||||
|
|||||||
@@ -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;
|
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||||
includes the pairing ceremony + `--require-pairing` gate),
|
includes the pairing ceremony + `--require-pairing` gate),
|
||||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter**
|
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter is now the DEFAULT**
|
||||||
(`VTDecompressionSession` + `CAMetalLayer`) is built and live-validated on glass behind the opt-in
|
(stage-1 is the Metal-unavailable / DEBUG fallback): explicit `VTDecompressionSession` decode →
|
||||||
`punktfunk.presenter` flag (~11 ms p50 capture→present), to become the default after a few
|
`CAMetalLayer`, presented from the hosting view's **main-runloop `CADisplayLink`** (`renderTick` pops
|
||||||
resolution/HDR checks. Next: make stage 2 the default, glass-to-glass numbers via
|
the newest ready frame per vsync; macOS `displaySyncEnabled = false` is the real fullscreen-judder fix,
|
||||||
`tools/latency-probe`, iOS/iPadOS/tvOS variants.
|
~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
|
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
||||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Contributing to punktfunk
|
||||||
|
|
||||||
|
Thanks for your interest in contributing!
|
||||||
|
|
||||||
|
## Licensing of contributions (inbound = outbound)
|
||||||
|
|
||||||
|
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
||||||
|
|
||||||
|
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||||
|
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
|
||||||
|
> Apache-2.0**, without any additional terms or conditions.
|
||||||
|
|
||||||
|
By opening a pull request you agree to license your contribution under these terms. This is the
|
||||||
|
standard Rust-ecosystem "inbound = outbound" model; it keeps the project's licensing unambiguous
|
||||||
|
(including the Apache-2.0 §5 contributor patent grant) and any future relicensing clean. You retain
|
||||||
|
the copyright to your contributions.
|
||||||
|
|
||||||
|
### Do not paste copyleft (or otherwise incompatibly-licensed) code
|
||||||
|
|
||||||
|
The single thing that could poison the permissive license is **copied source from a copyleft
|
||||||
|
project**. Several adjacent projects (Sunshine, Apollo, Moonlight) are GPL-3.0. You may study them
|
||||||
|
and reimplement a *technique*, protocol, or wire format — those are not copyrightable — but **never
|
||||||
|
paste their code**, and do not translate a GPL implementation line-by-line. When a comment credits
|
||||||
|
prior art, make clear it is an independent reimplementation, not a copy. The same applies to any
|
||||||
|
third party's code under a license incompatible with MIT/Apache.
|
||||||
|
|
||||||
|
If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0 / BSD / ISC / Zlib /
|
||||||
|
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
|
||||||
|
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||||
|
|
||||||
|
## Before you push
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
|
||||||
|
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
|
||||||
|
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
|
||||||
|
|
||||||
|
See [`CLAUDE.md`](CLAUDE.md) for the full build/test/run guide and design invariants.
|
||||||
Generated
+113
-293
@@ -137,18 +137,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.103"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arc-swap"
|
|
||||||
version = "1.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
|
||||||
dependencies = [
|
|
||||||
"rustversion",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ash"
|
name = "ash"
|
||||||
@@ -161,13 +152,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ashpd"
|
name = "ashpd"
|
||||||
version = "0.13.11"
|
version = "0.13.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32"
|
checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -358,23 +349,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-server"
|
name = "axum-server"
|
||||||
version = "0.7.3"
|
version = "0.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"either",
|
||||||
"fs-err",
|
"fs-err",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"pin-project-lite",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pemfile",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -476,9 +462,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cairo-rs"
|
name = "cairo-rs"
|
||||||
@@ -520,9 +506,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbindgen"
|
name = "cbindgen"
|
||||||
version = "0.29.3"
|
version = "0.29.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c95537b45400390270fae69ac098d057c8f5399001cde9d04f700c105ddfff2d"
|
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"heck",
|
"heck",
|
||||||
@@ -539,9 +525,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.63"
|
version = "1.2.65"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -906,9 +892,6 @@ name = "deranged"
|
|||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
dependencies = [
|
|
||||||
"powerfmt",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
@@ -1127,9 +1110,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
|
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -1142,12 +1125,6 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foldhash"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1376,15 +1353,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
|
||||||
"wasip3",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1595,9 +1570,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.14"
|
version = "0.4.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1623,22 +1598,13 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.15.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
|
||||||
dependencies = [
|
|
||||||
"foldhash 0.1.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1647,7 +1613,7 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1858,12 +1824,6 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "id-arena"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2014,9 +1974,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.100"
|
version = "0.3.103"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -2035,7 +1995,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2046,12 +2006,6 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "leb128fmt"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libadwaita"
|
name = "libadwaita"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -2167,13 +2121,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.32"
|
version = "0.4.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2201,9 +2155,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mdns-sd"
|
name = "mdns-sd"
|
||||||
version = "0.20.0"
|
version = "0.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
|
checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"flume",
|
"flume",
|
||||||
@@ -2216,15 +2170,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.1"
|
version = "2.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
version = "0.9.10"
|
version = "0.9.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
|
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -2377,6 +2331,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
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]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@@ -2716,16 +2681,6 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prettyplease"
|
|
||||||
version = "0.2.37"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
@@ -2765,7 +2720,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2779,7 +2734,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2799,7 +2754,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2819,7 +2774,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2849,7 +2804,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -2885,7 +2840,6 @@ dependencies = [
|
|||||||
"rsa",
|
"rsa",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
|
||||||
"rusty_enet",
|
"rusty_enet",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2896,12 +2850,14 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"usbip-sim",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-axum",
|
"utoipa-axum",
|
||||||
"utoipa-scalar",
|
"utoipa-scalar",
|
||||||
"wasapi",
|
"wasapi",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
"wayland-protocols-misc",
|
"wayland-protocols-misc",
|
||||||
"wayland-protocols-wlr",
|
"wayland-protocols-wlr",
|
||||||
"wayland-scanner",
|
"wayland-scanner",
|
||||||
@@ -2914,7 +2870,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -2943,9 +2899,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -2963,9 +2919,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.14"
|
version = "0.11.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"fastbloom",
|
"fastbloom",
|
||||||
@@ -3000,9 +2956,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
@@ -3156,9 +3112,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -3179,9 +3135,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.8.10"
|
version = "0.8.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reis"
|
name = "reis"
|
||||||
@@ -3309,9 +3265,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
@@ -3335,15 +3291,6 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-pemfile"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
|
||||||
dependencies = [
|
|
||||||
"rustls-pki-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.1"
|
version = "1.14.1"
|
||||||
@@ -3740,19 +3687,19 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket-pktinfo"
|
name = "socket-pktinfo"
|
||||||
version = "0.3.2"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f"
|
checksum = "3e8e43b4bdce7cff8a4d3f8025ee38fce5ca138fab868ebbf9529c81328fbf9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"socket2",
|
"socket2",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3828,9 +3775,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.118"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3880,7 +3827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -3937,12 +3884,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.47"
|
version = "0.3.51"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -3952,15 +3898,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-core"
|
name = "time-core"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-macros"
|
name = "time-macros"
|
||||||
version = "0.2.27"
|
version = "0.2.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"time-core",
|
"time-core",
|
||||||
@@ -4259,12 +4205,6 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-xid"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -4309,6 +4249,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "usbip-sim"
|
||||||
|
version = "0.8.0"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -4372,9 +4323,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.2"
|
version = "1.23.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -4445,27 +4396,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.3+wasi-0.2.9"
|
version = "1.0.4+wasi-0.2.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wit-bindgen 0.57.1",
|
"wit-bindgen",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasip3"
|
|
||||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
|
||||||
dependencies = [
|
|
||||||
"wit-bindgen 0.51.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.123"
|
version = "0.2.126"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4476,9 +4418,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.123"
|
version = "0.2.126"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -4486,9 +4428,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.123"
|
version = "0.2.126"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4499,47 +4441,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.123"
|
version = "0.2.126"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-encoder"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
|
||||||
dependencies = [
|
|
||||||
"leb128fmt",
|
|
||||||
"wasmparser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-metadata"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"indexmap",
|
|
||||||
"wasm-encoder",
|
|
||||||
"wasmparser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmparser"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"hashbrown 0.15.5",
|
|
||||||
"indexmap",
|
|
||||||
"semver",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-backend"
|
name = "wayland-backend"
|
||||||
version = "0.3.15"
|
version = "0.3.15"
|
||||||
@@ -4567,9 +4475,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols"
|
name = "wayland-protocols"
|
||||||
version = "0.32.12"
|
version = "0.32.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
|
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -4635,9 +4543,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-root-certs"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.7"
|
version = "1.0.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
@@ -5195,100 +5103,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen"
|
|
||||||
version = "0.51.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
|
||||||
dependencies = [
|
|
||||||
"wit-bindgen-rust-macro",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-core"
|
|
||||||
version = "0.51.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"heck",
|
|
||||||
"wit-parser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-rust"
|
|
||||||
version = "0.51.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"heck",
|
|
||||||
"indexmap",
|
|
||||||
"prettyplease",
|
|
||||||
"syn",
|
|
||||||
"wasm-metadata",
|
|
||||||
"wit-bindgen-core",
|
|
||||||
"wit-component",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-rust-macro"
|
|
||||||
version = "0.51.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"prettyplease",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
"wit-bindgen-core",
|
|
||||||
"wit-bindgen-rust",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-component"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"bitflags",
|
|
||||||
"indexmap",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"wasm-encoder",
|
|
||||||
"wasm-metadata",
|
|
||||||
"wasmparser",
|
|
||||||
"wit-parser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-parser"
|
|
||||||
version = "0.244.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"id-arena",
|
|
||||||
"indexmap",
|
|
||||||
"log",
|
|
||||||
"semver",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"unicode-xid",
|
|
||||||
"wasmparser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -5419,18 +5239,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.50"
|
version = "0.8.52"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.50"
|
version = "0.8.52"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -5460,9 +5280,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.8.2"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
+4
-1
@@ -3,6 +3,7 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"crates/punktfunk-core",
|
"crates/punktfunk-core",
|
||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
|
"crates/punktfunk-host/vendor/usbip-sim",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
@@ -11,9 +12,11 @@ members = [
|
|||||||
"tools/latency-probe",
|
"tools/latency-probe",
|
||||||
"tools/loss-harness",
|
"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]
|
[workspace.package]
|
||||||
version = "0.0.1"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -155,4 +155,31 @@ tools/ latency-probe · loss-harness (measurement)
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT OR Apache-2.0.
|
Licensed under either of
|
||||||
|
|
||||||
|
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
|
||||||
|
<https://www.apache.org/licenses/LICENSE-2.0>)
|
||||||
|
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <https://opensource.org/licenses/MIT>)
|
||||||
|
|
||||||
|
at your option — `SPDX-License-Identifier: MIT OR Apache-2.0`.
|
||||||
|
|
||||||
|
### Contribution
|
||||||
|
|
||||||
|
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||||
|
the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any
|
||||||
|
additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
### Third-party components
|
||||||
|
|
||||||
|
punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||||
|
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
|
||||||
|
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
|
||||||
|
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||||
|
notice ship in the installed `licenses/` folder).
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
|
||||||
|
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
|
||||||
|
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
|
||||||
|
here only to describe interoperability.
|
||||||
|
|||||||
+16154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
THIRD-PARTY SOFTWARE NOTICES
|
||||||
|
============================================================================
|
||||||
|
|
||||||
|
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||||
|
The binaries it ships statically/dynamically link the third-party Rust crates below.
|
||||||
|
Each is distributed under its own permissive license; full texts follow.
|
||||||
|
Generated by `cargo about generate about.hbs` (see about.toml) — do not edit by hand.
|
||||||
|
|
||||||
|
Overview:
|
||||||
|
{{#each overview}}
|
||||||
|
{{name}} ({{id}}): {{count}} crate(s)
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
{{#each licenses}}
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
{{name}} ({{id}})
|
||||||
|
Used by:
|
||||||
|
{{#each used_by}} - {{crate.name}} {{crate.version}}{{#if crate.repository}} ({{crate.repository}}){{/if}}
|
||||||
|
{{/each}}
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
{{text}}
|
||||||
|
|
||||||
|
{{/each}}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# cargo-about config — full-fidelity third-party license harvest for CI.
|
||||||
|
#
|
||||||
|
# cargo install cargo-about
|
||||||
|
# cargo about generate about.hbs > THIRD-PARTY-NOTICES.txt # (or use scripts/gen-third-party-notices.sh)
|
||||||
|
#
|
||||||
|
# `accepted` is the allow-list of SPDX licenses permitted in the dependency tree. CI fails if a crate
|
||||||
|
# carries anything not listed here — which is exactly the regression guard we want against a copyleft
|
||||||
|
# dependency silently entering the linked set. All entries
|
||||||
|
# below are permissive / attribution-only; deliberately NO GPL/LGPL/AGPL/MPL-link/SSPL/EPL.
|
||||||
|
#
|
||||||
|
# The dependency-free fallback is scripts/gen-third-party-notices.py (reads the cargo registry cache),
|
||||||
|
# which is what produced the committed baseline when cargo-about is unavailable offline.
|
||||||
|
|
||||||
|
accepted = [
|
||||||
|
"MIT",
|
||||||
|
"MIT-0",
|
||||||
|
"Apache-2.0",
|
||||||
|
"Apache-2.0 WITH LLVM-exception",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"Zlib",
|
||||||
|
"0BSD",
|
||||||
|
"BSL-1.0",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"Unicode-DFS-2016",
|
||||||
|
"CDLA-Permissive-2.0",
|
||||||
|
"CC0-1.0",
|
||||||
|
"Unlicense",
|
||||||
|
"WTFPL",
|
||||||
|
"OpenSSL",
|
||||||
|
]
|
||||||
|
|
||||||
|
# cbindgen is MPL-2.0 but it is a BUILD-ONLY codegen tool that never links into a shipped artifact
|
||||||
|
# (its generated header is not a derivative work), so it is excluded from the notices rather than
|
||||||
|
# accepted as a linked license.
|
||||||
|
ignore-build-dependencies = true
|
||||||
|
ignore-dev-dependencies = true
|
||||||
|
|
||||||
|
# r-efi offers an LGPL-2.1-or-later arm but is tri-licensed; take a permissive arm. (It is also
|
||||||
|
# UEFI-target-gated out of every shipped build.)
|
||||||
|
[r-efi.clarify]
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
[ring.clarify]
|
||||||
|
license = "MIT AND ISC AND OpenSSL"
|
||||||
|
|
||||||
|
[aws-lc-sys.clarify]
|
||||||
|
license = "ISC AND Apache-2.0 AND MIT AND BSD-3-Clause AND OpenSSL"
|
||||||
+9
-1
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.0.1"
|
"version": "0.3.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -1354,6 +1354,14 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Arm-native-pairing request body.",
|
"description": "Arm-native-pairing request body.",
|
||||||
"properties": {
|
"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": {
|
"ttl_secs": {
|
||||||
"type": [
|
"type": [
|
||||||
"integer",
|
"integer",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,10 +74,31 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
|||||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||||
import io.unom.punktfunk.models.HostStatus
|
import io.unom.punktfunk.models.HostStatus
|
||||||
import io.unom.punktfunk.models.PendingTrust
|
import io.unom.punktfunk.models.PendingTrust
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */
|
||||||
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
|
||||||
|
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than
|
||||||
|
* timing the client out first. Mirrors the Linux client's 185 s.
|
||||||
|
*/
|
||||||
|
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A no-PIN "request access" connect in flight — the host being requested (drives the cancelable
|
||||||
|
* "Waiting for approval…" dialog) and a per-attempt flag the Cancel button trips. The connect is a
|
||||||
|
* blocking call with no abort, so Cancel returns the UI immediately and a late result checks
|
||||||
|
* [cancelled] and tears the (possibly just-approved) session down silently rather than navigating.
|
||||||
|
*/
|
||||||
|
private class RequestAccessState(val target: PendingTrust) {
|
||||||
|
val cancelled = AtomicBoolean(false)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||||
@@ -128,8 +149,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
.onSuccess { identity = it }
|
.onSuccess { identity = it }
|
||||||
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
|
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
|
||||||
}
|
}
|
||||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
|
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing / the
|
||||||
|
// request-access-or-PIN choice).
|
||||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||||
|
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||||
|
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||||
// A saved host whose label is being edited (the Rename dialog).
|
// A saved host whose label is being edited (the Rename dialog).
|
||||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||||
|
|
||||||
@@ -151,9 +175,9 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = "Connecting to $targetHost:$targetPort…"
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Advertise HDR only when this device's display can present it (else the host sends a
|
// Advertise HDR only when the user enabled it AND this device's display can present it
|
||||||
// proper SDR stream rather than PQ the panel would mis-tone-map).
|
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||||
val hdrEnabled = displaySupportsHdr(context)
|
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
// "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
|
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||||
// explicit choice is passed through unchanged.
|
// explicit choice is passed through unchanged.
|
||||||
@@ -163,7 +187,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
targetHost, targetPort, w, h, hz,
|
targetHost, targetPort, w, h, hz,
|
||||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
hdrEnabled, settings.audioChannels,
|
hdrEnabled, settings.audioChannels, CONNECT_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
connecting = false
|
connecting = false
|
||||||
@@ -182,10 +206,66 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
|
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
||||||
|
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
||||||
|
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
|
||||||
|
// reconnect), so on success we record the host as PAIRED — the operator's approval IS the pairing.
|
||||||
|
// The connect can't be aborted, so Cancel returns the UI immediately and a late result is torn
|
||||||
|
// down silently via the per-attempt flag (mirrors the Linux client's request-access flow).
|
||||||
|
fun requestAccess(target: PendingTrust) {
|
||||||
|
val id = identity
|
||||||
|
if (id == null) {
|
||||||
|
status = "Identity not ready yet — try again in a moment"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val req = RequestAccessState(target)
|
||||||
|
awaiting = req
|
||||||
|
connecting = true
|
||||||
|
status = null
|
||||||
|
discovery.stop() // free the Wi-Fi radio before the (parked) stream session
|
||||||
|
scope.launch {
|
||||||
|
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.
|
||||||
|
val pinHex = target.advertisedFp ?: ""
|
||||||
|
val handle = withContext(Dispatchers.IO) {
|
||||||
|
NativeBridge.nativeConnect(
|
||||||
|
target.host, target.port, w, h, hz,
|
||||||
|
id.certPem, id.privateKeyPem, pinHex,
|
||||||
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
|
hdrEnabled, settings.audioChannels, REQUEST_ACCESS_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Cancelled while we were parked: tear the (possibly just-approved) session down and
|
||||||
|
// don't touch UI a fresh action may now own.
|
||||||
|
if (req.cancelled.get()) {
|
||||||
|
if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
awaiting = null
|
||||||
|
connecting = false
|
||||||
|
if (handle != 0L) {
|
||||||
|
// Approved — save the host as PAIRED, pinning the fingerprint it presented, so
|
||||||
|
// future connects are silent (exactly like after a PIN ceremony).
|
||||||
|
val fp = NativeBridge.nativeHostFingerprint(handle)
|
||||||
|
if (fp.isNotEmpty()) {
|
||||||
|
knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true))
|
||||||
|
savedHosts = knownHostStore.all()
|
||||||
|
}
|
||||||
|
onConnected(handle)
|
||||||
|
} else {
|
||||||
|
status = "Request timed out — approve this device in the host's console, then retry."
|
||||||
|
discovery.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide pinned-reconnect vs fp-changed vs TOFU vs pairing before connecting. Trust state is
|
||||||
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
||||||
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
||||||
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
|
// pair=required host, or a manual/unknown-policy host, must pair — either by no-PIN request
|
||||||
|
// access (approve in the console) or by the SPAKE2 PIN ceremony.
|
||||||
fun connect(
|
fun connect(
|
||||||
targetHost: String,
|
targetHost: String,
|
||||||
targetPort: Int,
|
targetPort: Int,
|
||||||
@@ -208,9 +288,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
||||||
dh?.pairingRequired == false -> pendingTrust =
|
dh?.pairingRequired == false -> pendingTrust =
|
||||||
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
||||||
// pair=required, or a manual/unknown-policy host → PIN pairing is mandatory.
|
// pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN
|
||||||
|
// "request access" (approve in the console) or the SPAKE2 PIN ceremony.
|
||||||
else -> pendingTrust =
|
else -> pendingTrust =
|
||||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR)
|
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +552,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request
|
||||||
|
// access" is the no-PIN path — connect and wait for the operator to click Approve in the
|
||||||
|
// host's console; "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||||
|
PendingTrust.Kind.REQUEST_ACCESS -> AlertDialog(
|
||||||
|
onDismissRequest = { pendingTrust = null },
|
||||||
|
title = { Text("Pairing required") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||||
|
Text(
|
||||||
|
"Request access and approve this device in the host's console (or web " +
|
||||||
|
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton({ pendingTrust = null; requestAccess(pt) }) { Text("Request access") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
|
||||||
|
Text("Use a PIN…")
|
||||||
|
}
|
||||||
|
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
PendingTrust.Kind.PAIR -> {
|
PendingTrust.Kind.PAIR -> {
|
||||||
var pin by remember(pt) { mutableStateOf("") }
|
var pin by remember(pt) { mutableStateOf("") }
|
||||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||||
@@ -537,6 +645,44 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The no-PIN "request access" wait: the connect is parked on the host until the operator
|
||||||
|
// approves this device. Cancel returns the UI immediately — it trips the per-attempt flag so a
|
||||||
|
// late approval is torn down silently (see requestAccess) and resumes discovery.
|
||||||
|
awaiting?.let { req ->
|
||||||
|
fun cancel() {
|
||||||
|
req.cancelled.set(true)
|
||||||
|
awaiting = null
|
||||||
|
connecting = false
|
||||||
|
discovery.start() // the request may still be pending on the host; keep scanning
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { cancel() },
|
||||||
|
title = { Text("Waiting for approval") },
|
||||||
|
text = {
|
||||||
|
val deviceName = Build.MODEL ?: "this device"
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
Text("Approve this device on ${req.target.name}.")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||||
|
"automatically once you approve — no PIN needed.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { cancel() }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||||
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||||
renameTarget?.let { kh ->
|
renameTarget?.let { kh ->
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open-source licenses: punktfunk's own license (MIT OR Apache-2.0) plus the third-party software
|
||||||
|
* notices, read from the bundled `THIRD-PARTY-NOTICES.txt` asset (generated by
|
||||||
|
* scripts/gen-third-party-notices.sh). Reached from [SettingsScreen]; Back returns there.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LicensesScreen(onBack: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
val notices = remember {
|
||||||
|
runCatching {
|
||||||
|
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
||||||
|
}.getOrDefault("Third-party notices unavailable.")
|
||||||
|
}
|
||||||
|
val version = remember {
|
||||||
|
runCatching {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
if (version != null) {
|
||||||
|
Text(
|
||||||
|
"punktfunk $version",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||||
|
"components below, each under its own license.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
notices,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,13 @@ data class Settings(
|
|||||||
val height: Int = 0,
|
val height: Int = 0,
|
||||||
val hz: Int = 0,
|
val hz: Int = 0,
|
||||||
val bitrateKbps: 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 compositor: Int = 0,
|
||||||
val gamepad: 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
|
/** 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),
|
height = prefs.getInt(K_H, 0),
|
||||||
hz = prefs.getInt(K_HZ, 0),
|
hz = prefs.getInt(K_HZ, 0),
|
||||||
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
||||||
|
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||||
@@ -54,6 +62,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putInt(K_H, s.height)
|
.putInt(K_H, s.height)
|
||||||
.putInt(K_HZ, s.hz)
|
.putInt(K_HZ, s.hz)
|
||||||
.putInt(K_BITRATE, s.bitrateKbps)
|
.putInt(K_BITRATE, s.bitrateKbps)
|
||||||
|
.putBoolean(K_HDR, s.hdrEnabled)
|
||||||
.putInt(K_COMPOSITOR, s.compositor)
|
.putInt(K_COMPOSITOR, s.compositor)
|
||||||
.putInt(K_GAMEPAD, s.gamepad)
|
.putInt(K_GAMEPAD, s.gamepad)
|
||||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||||
@@ -68,6 +77,7 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_H = "height"
|
const val K_H = "height"
|
||||||
const val K_HZ = "hz"
|
const val K_HZ = "hz"
|
||||||
const val K_BITRATE = "bitrate_kbps"
|
const val K_BITRATE = "bitrate_kbps"
|
||||||
|
const val K_HDR = "hdr_enabled"
|
||||||
const val K_COMPOSITOR = "compositor"
|
const val K_COMPOSITOR = "compositor"
|
||||||
const val K_GAMEPAD = "gamepad"
|
const val K_GAMEPAD = "gamepad"
|
||||||
const val K_AUDIO_CH = "audio_channels"
|
const val K_AUDIO_CH = "audio_channels"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.pm.PackageManager
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
@@ -44,6 +45,7 @@ import androidx.core.content.ContextCompat
|
|||||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
||||||
var s by remember { mutableStateOf(initial) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
var showLicenses by remember { mutableStateOf(false) }
|
||||||
fun update(next: Settings) {
|
fun update(next: Settings) {
|
||||||
s = next
|
s = next
|
||||||
onChange(next)
|
onChange(next)
|
||||||
@@ -56,6 +58,11 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { granted -> update(s.copy(micEnabled = granted)) }
|
) { granted -> update(s.copy(micEnabled = granted)) }
|
||||||
|
|
||||||
|
if (showLicenses) {
|
||||||
|
LicensesScreen(onBack = { showLicenses = false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -87,6 +94,22 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
options = BITRATE_OPTIONS,
|
options = BITRATE_OPTIONS,
|
||||||
selected = s.bitrateKbps,
|
selected = s.bitrateKbps,
|
||||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
) { 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") {
|
SettingsGroup("Host") {
|
||||||
@@ -143,6 +166,14 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsGroup("About") {
|
||||||
|
ClickableRow(
|
||||||
|
title = "Open-source licenses",
|
||||||
|
subtitle = "Third-party notices and credits",
|
||||||
|
onClick = { showLicenses = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,15 +197,41 @@ 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
|
@Composable
|
||||||
private fun ToggleRow(
|
private fun ToggleRow(
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
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) {
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = labelAlpha),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = labelAlpha),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A title + subtitle on the left; the whole row is clickable (opens a sub-screen). */
|
||||||
|
@Composable
|
||||||
|
private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||||
Text(
|
Text(
|
||||||
@@ -183,7 +240,6 @@ private fun ToggleRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.unom.punktfunk
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
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.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
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?.streamHandle = handle // route hardware keys to this session
|
||||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
// 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
|
activity?.streamHandle = 0L
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
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.
|
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
|
||||||
NativeBridge.nativeStopMic(handle)
|
NativeBridge.nativeStopMic(handle)
|
||||||
NativeBridge.nativeStopAudio(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]:
|
* [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
|
@Composable
|
||||||
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
||||||
@@ -338,6 +351,14 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
|
|||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
|
videoFeedLine(s)?.let { feed ->
|
||||||
|
Text(
|
||||||
|
feed,
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (latValid) {
|
if (latValid) {
|
||||||
val tag = if (skew) "" else " (same-host)"
|
val tag = if (skew) "" else " (same-host)"
|
||||||
Text(
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ enum class Tab(val label: String, val icon: ImageVector) {
|
|||||||
/**
|
/**
|
||||||
* A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
|
* A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
|
||||||
* host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
|
* host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
|
||||||
* pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN
|
* pair=optional; a pair=required host or a manually-typed/unknown-policy host is offered the
|
||||||
* pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust.
|
* two ways in ([Kind.REQUEST_ACCESS]): a no-PIN "request access" connect the operator approves in
|
||||||
|
* the host's console, or the SPAKE2 PIN ceremony ([Kind.PAIR]). A changed fingerprint forces
|
||||||
|
* re-pairing by PIN ([Kind.FP_CHANGED]) — never a silent re-trust.
|
||||||
*/
|
*/
|
||||||
data class PendingTrust(
|
data class PendingTrust(
|
||||||
val host: String,
|
val host: String,
|
||||||
@@ -24,7 +26,7 @@ data class PendingTrust(
|
|||||||
val advertisedFp: String?,
|
val advertisedFp: String?,
|
||||||
val kind: Kind,
|
val kind: Kind,
|
||||||
) {
|
) {
|
||||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR }
|
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Trust state of a host, shown as a colored pill on its card. */
|
/** Trust state of a host, shown as a colored pill on its card. */
|
||||||
|
|||||||
@@ -186,9 +186,11 @@ internal fun StreamScene() {
|
|||||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
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(
|
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),
|
Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,15 +50,25 @@ object Gamepad {
|
|||||||
const val PREF_DUALSENSE = 2
|
const val PREF_DUALSENSE = 2
|
||||||
const val PREF_XBOXONE = 3
|
const val PREF_XBOXONE = 3
|
||||||
const val PREF_DUALSHOCK4 = 4
|
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.
|
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||||
private const val VID_SONY = 0x054C
|
private const val VID_SONY = 0x054C
|
||||||
private const val VID_MICROSOFT = 0x045E
|
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.
|
// 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_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
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
|
// 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.
|
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||||
private val PID_XBOXONE = setOf(
|
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_DUALSENSE -> PREF_DUALSENSE
|
||||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
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
|
else -> PREF_XBOX360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ object NativeBridge {
|
|||||||
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
|
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
|
||||||
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
|
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
|
||||||
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
|
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
|
||||||
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). Returns an opaque session handle, or `0`
|
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). [timeoutMs] is the handshake budget — the
|
||||||
* on failure. Pair with exactly one [nativeClose].
|
* normal path passes a short value, the no-PIN "request access" path a long one (≥ the host's
|
||||||
|
* approval-park window) so a slow operator approval lands on this same parked connection. Returns
|
||||||
|
* an opaque session handle, or `0` on failure. Pair with exactly one [nativeClose].
|
||||||
*/
|
*/
|
||||||
external fun nativeConnect(
|
external fun nativeConnect(
|
||||||
host: String,
|
host: String,
|
||||||
@@ -46,6 +48,7 @@ object NativeBridge {
|
|||||||
gamepadPref: Int,
|
gamepadPref: Int,
|
||||||
hdrEnabled: Boolean,
|
hdrEnabled: Boolean,
|
||||||
audioChannels: Int,
|
audioChannels: Int,
|
||||||
|
timeoutMs: Int,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||||
@@ -100,9 +103,12 @@ object NativeBridge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
|
||||||
* Returns 10 doubles:
|
* Returns 14 doubles:
|
||||||
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
|
* `[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.
|
* 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?
|
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);
|
out[2..n].copy_from_slice(&effect);
|
||||||
n
|
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
|
n as jint
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -140,13 +140,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels): Long`. `certPem`/`keyPem` empty =
|
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, timeoutMs): Long`. `certPem`/`keyPem`
|
||||||
/// anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read
|
/// empty = anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read
|
||||||
/// `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). `bitrateKbps`
|
/// `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). `bitrateKbps`
|
||||||
/// 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes
|
/// 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes
|
||||||
/// (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8; normalized,
|
/// (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8; normalized,
|
||||||
/// anything else → stereo) — the host clamps it and the resolved count drives playback.
|
/// anything else → stereo) — the host clamps it and the resolved count drives playback. `timeoutMs`
|
||||||
/// Returns an opaque handle, or 0 on failure (logged).
|
/// is the handshake budget: the normal path passes a short value, the no-PIN "request access" path a
|
||||||
|
/// long one (≥ the host's approval-park window) so a slow operator approval lands on this same parked
|
||||||
|
/// connection rather than timing the client out first. Returns an opaque handle, or 0 on failure.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||||
@@ -165,6 +167,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
gamepad_pref: jint,
|
gamepad_pref: jint,
|
||||||
hdr_enabled: jboolean,
|
hdr_enabled: jboolean,
|
||||||
audio_channels: jint,
|
audio_channels: jint,
|
||||||
|
timeout_ms: jint,
|
||||||
) -> jlong {
|
) -> jlong {
|
||||||
let host: String = match env.get_string(&host) {
|
let host: String = match env.get_string(&host) {
|
||||||
Ok(s) => s.into(),
|
Ok(s) => s.into(),
|
||||||
@@ -224,7 +227,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
None, // launch: default app
|
None, // launch: default app
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
Duration::from_secs(10),
|
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||||
|
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||||
|
Duration::from_millis(timeout_ms.max(0) as u64),
|
||||||
) {
|
) {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
let handle = SessionHandle {
|
let handle = SessionHandle {
|
||||||
@@ -404,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.
|
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
||||||
/// Returns 10 doubles
|
/// Returns 14 doubles
|
||||||
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped]`
|
/// `[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;
|
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`
|
||||||
/// each call resets the measurement window. Not android-gated — pure `jni` + connector reads, so it
|
/// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), or
|
||||||
/// links on the host build too (Kotlin only ever calls it on device).
|
/// `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]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||||
env: JNIEnv,
|
env: JNIEnv,
|
||||||
@@ -426,7 +433,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
None => return std::ptr::null_mut(), // not streaming → no stats
|
None => return std::ptr::null_mut(), // not streaming → no stats
|
||||||
};
|
};
|
||||||
let mode = h.client.mode();
|
let mode = h.client.mode();
|
||||||
let buf: [f64; 10] = [
|
let color = h.client.color;
|
||||||
|
let buf: [f64; 14] = [
|
||||||
snap.fps,
|
snap.fps,
|
||||||
snap.mbps,
|
snap.mbps,
|
||||||
snap.lat_p50_ms,
|
snap.lat_p50_ms,
|
||||||
@@ -437,6 +445,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
mode.height as f64,
|
mode.height as f64,
|
||||||
mode.refresh_hz as f64,
|
mode.refresh_hz as f64,
|
||||||
h.client.frames_dropped() 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) {
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "PunktfunkKit",
|
name: "PunktfunkKit",
|
||||||
dependencies: ["PunktfunkCore"],
|
dependencies: ["PunktfunkCore"],
|
||||||
|
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
|
||||||
|
// app target) so it rides along via Bundle.module in both `swift build` and the Xcode
|
||||||
|
// app, which links the PunktfunkKit product. Refresh with
|
||||||
|
// scripts/gen-third-party-notices.sh (it copies the generated file into Resources/).
|
||||||
|
resources: [
|
||||||
|
.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: [
|
linkerSettings: [
|
||||||
// Rust staticlib system deps.
|
// Rust staticlib system deps.
|
||||||
.linkedFramework("Security"),
|
.linkedFramework("Security"),
|
||||||
|
|||||||
@@ -364,7 +364,7 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
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;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
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;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
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_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.";
|
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;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
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_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.";
|
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;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
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_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -536,7 +536,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
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_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the
|
||||||
|
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
|
||||||
|
struct AcknowledgementsView: View {
|
||||||
|
private var version: String? {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
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)
|
||||||
|
.padding()
|
||||||
|
#if os(tvOS)
|
||||||
|
.padding(40)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.navigationTitle("Acknowledgements")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `textSelection(.enabled)` is unavailable on tvOS, so apply it only where it exists.
|
||||||
|
private struct SelectableText: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
content
|
||||||
|
#else
|
||||||
|
content.textSelection(.enabled)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,11 @@ struct AddHostSheet: View {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
#endif
|
#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)
|
#if os(macOS)
|
||||||
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
||||||
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
// 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
|
// 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
|
// 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
|
// 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
|
// centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
|
||||||
// scrolls inside the detent — nothing is clipped. (.height(_:) is iOS 16+, safe at iOS 17.)
|
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||||
.presentationDetents([.height(320)])
|
.presentationDetents([.height(320)])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
#endif
|
#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
|
||||||
@@ -4,10 +4,12 @@
|
|||||||
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
|
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
|
||||||
// their own files.
|
// their own files.
|
||||||
//
|
//
|
||||||
// Two ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
// Ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
||||||
// live-but-blurred stream, compared with the host's log) or the PIN pairing ceremony — pairing
|
// live-but-blurred stream, compared with the host's log; only for a host advertising pair=optional),
|
||||||
// verifies both sides at once and is the only way into hosts running --require-pairing. Once
|
// the PIN pairing ceremony (verifies both sides at once), or — for a host that requires pairing —
|
||||||
// pinned, reconnects are silent and a changed host identity refuses to connect.
|
// delegated approval ("Request Access": a plain identified connect the host parks until the operator
|
||||||
|
// approves this device in its console, no PIN). Once pinned, reconnects are silent and a changed
|
||||||
|
// host identity refuses to connect.
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -26,11 +28,18 @@ struct ContentView: View {
|
|||||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||||
|
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@State private var showAddHost = false
|
@State private var showAddHost = false
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
|
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||||
|
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
||||||
|
@State private var approvalChoice: ApprovalRequest?
|
||||||
|
/// A delegated-approval connect is in flight (host parks it until the operator approves):
|
||||||
|
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
|
||||||
|
@State private var awaitingApproval: ApprovalRequest?
|
||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
@State private var libraryTarget: StoredHost?
|
@State private var libraryTarget: StoredHost?
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@@ -55,10 +64,31 @@ struct ContentView: View {
|
|||||||
autoConnectIfAsked()
|
autoConnectIfAsked()
|
||||||
}
|
}
|
||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
switch phase {
|
||||||
// plus the accent ring on the most recent host).
|
case .streaming:
|
||||||
if case .streaming = phase, let host = model.activeHost {
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
store.markConnected(host.id)
|
// plus the accent ring on the most recent host).
|
||||||
|
guard let host = model.activeHost else { break }
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
// wait prompt (SessionModel surfaces any error via `errorMessage`).
|
||||||
|
if awaitingApproval != nil { awaitingApproval = nil }
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
||||||
@@ -90,6 +120,47 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
|
||||||
|
// alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
|
||||||
|
// delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The follow-on
|
||||||
|
// presentation is deferred a tick so this dialog is fully dismissed first.
|
||||||
|
.confirmationDialog(
|
||||||
|
"Pairing required",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { approvalChoice != nil },
|
||||||
|
set: { if !$0 { approvalChoice = nil } }),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: approvalChoice
|
||||||
|
) { req in
|
||||||
|
Button("Request Access") {
|
||||||
|
DispatchQueue.main.async { requestAccess(req) }
|
||||||
|
}
|
||||||
|
Button("Pair with PIN…") {
|
||||||
|
DispatchQueue.main.async { pairingTarget = req.host }
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: { req in
|
||||||
|
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||||||
|
+ "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or "
|
||||||
|
+ "pair with the 4-digit PIN it can display.")
|
||||||
|
}
|
||||||
|
// The delegated-approval wait: the host holds the connection open until the operator
|
||||||
|
// approves it. Cancel returns the UI at once; the in-flight connect is left to time out
|
||||||
|
// and its late result is discarded by SessionModel's connect guard (disconnect resets the
|
||||||
|
// phase/host it checks).
|
||||||
|
.alert(
|
||||||
|
"Waiting for approval",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { awaitingApproval != nil },
|
||||||
|
set: { if !$0 { awaitingApproval = nil } }),
|
||||||
|
presenting: awaitingApproval
|
||||||
|
) { _ in
|
||||||
|
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||||
|
} message: { req in
|
||||||
|
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||||||
|
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||||
|
+ "approve it — no need to reconnect.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
@@ -230,19 +301,32 @@ struct ContentView: View {
|
|||||||
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
|
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
|
||||||
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
|
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
|
||||||
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
|
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
|
||||||
// an unpinned host with no matching `pair=optional` advert routes to PIN pairing instead
|
// an unpinned host with no matching `pair=optional` advert routes to the approval choice
|
||||||
// of silently entering the trust prompt (rules 3b + 4). A pinned host ignores all of this.
|
// (request access / pair with PIN) instead of silently entering the trust prompt (rules
|
||||||
|
// 3b + 4). A pinned host ignores all of this.
|
||||||
if host.pinnedSHA256 == nil {
|
if host.pinnedSHA256 == nil {
|
||||||
let tofuOK = allowTofu ?? discovery.hosts.contains {
|
let tofuOK = allowTofu ?? discovery.hosts.contains {
|
||||||
host.matches($0) && $0.allowsTofu
|
host.matches($0) && $0.allowsTofu
|
||||||
}
|
}
|
||||||
if !tofuOK {
|
if !tofuOK {
|
||||||
pairingTarget = host
|
// pair=required / unknown policy / manual entry (rule 3b): never a silent
|
||||||
|
// connect — offer no-PIN delegated approval or the PIN ceremony.
|
||||||
|
approvalChoice = ApprovalRequest(
|
||||||
|
host: host, advertisedFingerprint: advertisedFingerprint(for: host))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The gamepad-type setting resolves NOW (Automatic → match the active physical
|
startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil)
|
||||||
// controller): the host's virtual pad backend is fixed per session.
|
}
|
||||||
|
|
||||||
|
/// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The
|
||||||
|
/// gamepad-type setting resolves NOW (Automatic → match the active physical controller): the
|
||||||
|
/// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN
|
||||||
|
/// delegated-approval connect (host parks it until the operator approves).
|
||||||
|
private func startSession(
|
||||||
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
|
allowTofu: Bool, requestAccess: Bool = false
|
||||||
|
) {
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
@@ -254,8 +338,24 @@ struct ContentView: View {
|
|||||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||||
audioChannels: UInt8(clamping: audioChannels),
|
audioChannels: UInt8(clamping: audioChannels),
|
||||||
|
hdrEnabled: hdrEnabled,
|
||||||
launchID: launchID,
|
launchID: launchID,
|
||||||
allowTofu: host.pinnedSHA256 == nil)
|
allowTofu: allowTofu,
|
||||||
|
requestAccess: requestAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The no-PIN delegated-approval flow: open an identified connect the host parks until the
|
||||||
|
/// operator approves it in the console, showing the cancelable "Waiting for approval" prompt
|
||||||
|
/// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
|
||||||
|
/// as paired (see the `.streaming` branch of `onChange`).
|
||||||
|
private func requestAccess(_ req: ApprovalRequest) {
|
||||||
|
guard !model.isBusy else { return }
|
||||||
|
awaitingApproval = req
|
||||||
|
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
||||||
|
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
|
var host = req.host
|
||||||
|
host.pinnedSHA256 = req.advertisedFingerprint
|
||||||
|
startSession(host, allowTofu: false, requestAccess: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||||||
@@ -268,8 +368,9 @@ struct ContentView: View {
|
|||||||
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||||||
/// persists), then connect or pair per the host's advertised policy. The host is the policy
|
/// persists), then connect or pair per the host's advertised policy. The host is the policy
|
||||||
/// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a);
|
/// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a);
|
||||||
/// a `pair=required` host, or one with no/unknown `pair` field, goes straight to the PIN
|
/// a `pair=required` host, or one with no/unknown `pair` field, gets the approval choice
|
||||||
/// pairing ceremony (rule 3b). (A pinned discovered host connects silently inside `connect`.)
|
/// (request access / pair with PIN) (rule 3b). (A pinned discovered host connects silently
|
||||||
|
/// inside `connect`.)
|
||||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||||
guard !model.isBusy else { return }
|
guard !model.isBusy else { return }
|
||||||
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
||||||
@@ -277,7 +378,9 @@ struct ContentView: View {
|
|||||||
if d.allowsTofu {
|
if d.allowsTofu {
|
||||||
connect(host, allowTofu: true)
|
connect(host, allowTofu: true)
|
||||||
} else {
|
} else {
|
||||||
pairingTarget = host
|
// pair=required / unknown policy (rule 3b): offer no-PIN delegated approval or PIN.
|
||||||
|
approvalChoice = ApprovalRequest(
|
||||||
|
host: host, advertisedFingerprint: pinFingerprint(d.fingerprintHex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +394,30 @@ struct ContentView: View {
|
|||||||
connect(pinned)
|
connect(pinned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The certificate fingerprint a live mDNS advert carries for this saved host (advisory — see
|
||||||
|
/// `HostDiscovery`), to pin during a delegated-approval wait. nil if the host isn't currently
|
||||||
|
/// advertising or advertised no/invalid `fp`.
|
||||||
|
private func advertisedFingerprint(for host: StoredHost) -> Data? {
|
||||||
|
pinFingerprint(discovery.hosts.first { host.matches($0) }?.fingerprintHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an advertised cert fingerprint (lowercase hex) into the 32-byte pin the connect
|
||||||
|
/// expects; nil unless it's exactly a 32-byte (SHA-256) value, so a malformed advert falls
|
||||||
|
/// back to trust-on-first-use rather than failing the connect closed.
|
||||||
|
private func pinFingerprint(_ hex: String?) -> Data? {
|
||||||
|
guard let hex, let data = Data(hexString: hex), data.count == 32 else { return nil }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the host lists this device in its approval prompt (matches PairSheet's client name).
|
||||||
|
private var localDeviceName: String {
|
||||||
|
#if os(macOS)
|
||||||
|
Host.current().localizedName ?? "Mac"
|
||||||
|
#else
|
||||||
|
UIDevice.current.name
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - First-run + dev hooks
|
// MARK: - First-run + dev hooks
|
||||||
|
|
||||||
/// First run on iOS: default the stream mode to this device's native screen so the
|
/// First run on iOS: default the stream mode to this device's native screen so the
|
||||||
@@ -354,6 +481,7 @@ struct ContentView: View {
|
|||||||
gamepad: pad,
|
gamepad: pad,
|
||||||
bitrateKbps: bitrate,
|
bitrateKbps: bitrate,
|
||||||
audioChannels: UInt8(clamping: audioChannels),
|
audioChannels: UInt8(clamping: audioChannels),
|
||||||
|
hdrEnabled: hdrEnabled,
|
||||||
autoTrust: true)
|
autoTrust: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,3 +506,31 @@ private struct FullscreenController: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
||||||
|
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
||||||
|
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
||||||
|
private struct ApprovalRequest {
|
||||||
|
let host: StoredHost
|
||||||
|
let advertisedFingerprint: Data?
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
/// Parse an even-length hex string into bytes; nil on any non-hex character or odd length.
|
||||||
|
/// Used to turn an mDNS-advertised cert fingerprint into a connect pin.
|
||||||
|
init?(hexString: String) {
|
||||||
|
let chars = Array(hexString)
|
||||||
|
guard chars.count.isMultiple(of: 2) else { return nil }
|
||||||
|
var bytes = [UInt8]()
|
||||||
|
bytes.reserveCapacity(chars.count / 2)
|
||||||
|
var i = 0
|
||||||
|
while i < chars.count {
|
||||||
|
guard let hi = chars[i].hexDigitValue, let lo = chars[i + 1].hexDigitValue else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bytes.append(UInt8(hi << 4 | lo))
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
self = Data(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ struct ControllerTestView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Test Controller").font(.headline)
|
Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
}
|
}
|
||||||
@@ -99,8 +99,8 @@ struct ControllerTestView: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(c.name).font(.headline)
|
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ struct ControllerTestView: View {
|
|||||||
) -> some View {
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||||
fingerDot(tp.primary, color: .accentColor)
|
fingerDot(tp.primary, color: .accentColor)
|
||||||
@@ -230,7 +230,7 @@ struct ControllerTestView: View {
|
|||||||
private func motionReadout(_ m: GCMotion) -> some View {
|
private func motionReadout(_ m: GCMotion) -> some View {
|
||||||
let a = Self.totalAccel(m)
|
let a = Self.totalAccel(m)
|
||||||
return VStack(alignment: .leading, spacing: 2) {
|
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",
|
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||||
.font(.caption2.monospaced())
|
.font(.caption2.monospaced())
|
||||||
@@ -254,11 +254,11 @@ struct ControllerTestView: View {
|
|||||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||||
Toggle("Light motor (right)", isOn: $lightOn)
|
Toggle("Light motor (right)", isOn: $lightOn)
|
||||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||||
.font(.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 "
|
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||||
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||||
+ "can't reach its motors on macOS).")
|
+ "can't reach its motors on macOS).")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||||
.onChange(of: lightOn) { _, _ 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.")
|
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Adaptive triggers need a DualSense.")
|
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
|
_ title: String, @ViewBuilder _ content: () -> Content
|
||||||
) -> some View {
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text(title).font(.subheadline.weight(.semibold))
|
Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -127,14 +127,13 @@ struct HomeView: View {
|
|||||||
AddHostSheet { store.add($0) }
|
AddHostSheet { store.add($0) }
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#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) {
|
.sheet(isPresented: $showSettings) {
|
||||||
NavigationStack {
|
SettingsView()
|
||||||
SettingsView()
|
.settingsSheetSizing()
|
||||||
.navigationTitle("Settings")
|
|
||||||
.toolbar {
|
|
||||||
Button("Done") { showSettings = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
@@ -172,7 +171,7 @@ struct HomeView: View {
|
|||||||
private var discoveredSection: some View {
|
private var discoveredSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||||
.font(.headline)
|
.font(.geist(15, .semibold, relativeTo: .headline))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
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
|
/// 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.
|
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
||||||
private var gridColumns: [GridItem] {
|
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)
|
#if os(macOS)
|
||||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||||
#else
|
#else
|
||||||
|
|||||||
@@ -1,26 +1,75 @@
|
|||||||
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
// 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 PunktfunkKit
|
||||||
import SwiftUI
|
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 {
|
private struct CardMetrics {
|
||||||
let iconSize: CGFloat
|
let tile: CGFloat // monogram tile side
|
||||||
let iconBox: CGFloat
|
let monogram: CGFloat // monogram letter point size
|
||||||
let cardPadding: CGFloat
|
let name: CGFloat // host-name point size
|
||||||
let nameFont: Font
|
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 {
|
static var current: CardMetrics {
|
||||||
#if os(iOS)
|
#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
|
#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
|
#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.
|
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||||
struct HostCardView: View {
|
struct HostCardView: View {
|
||||||
let host: StoredHost
|
let host: StoredHost
|
||||||
@@ -41,66 +90,44 @@ struct HostCardView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let m = CardMetrics.current
|
let m = CardMetrics.current
|
||||||
return Button(action: onConnect) {
|
return Button(action: onConnect) {
|
||||||
VStack(spacing: 10) {
|
HStack(spacing: m.spacing) {
|
||||||
ZStack {
|
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||||
Image(systemName: "play.display")
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.font(.system(size: m.iconSize, weight: .light))
|
Text(host.displayName)
|
||||||
.foregroundStyle(.tint)
|
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||||
.opacity(isConnecting ? 0.3 : 1)
|
.foregroundStyle(.primary)
|
||||||
if isConnecting {
|
.lineLimit(1)
|
||||||
ProgressView()
|
Text("\(host.address):\(String(host.port))")
|
||||||
}
|
.font(.geist(m.meta, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
statusRow(m)
|
||||||
}
|
}
|
||||||
.frame(height: m.iconBox)
|
Spacer(minLength: 0)
|
||||||
VStack(spacing: 2) {
|
}
|
||||||
HStack(spacing: 6) {
|
.padding(m.padding)
|
||||||
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Circle()
|
#if !os(tvOS)
|
||||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
||||||
.frame(width: 7, height: 7)
|
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
||||||
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
// and a brand accent bar down the leading edge for the most-recent host.
|
||||||
Text(host.displayName)
|
.background(.regularMaterial)
|
||||||
.font(m.nameFont)
|
.overlay(alignment: .leading) {
|
||||||
.lineLimit(1)
|
if isMostRecent {
|
||||||
}
|
Rectangle().fill(Color.brand).frame(width: 3)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||||
.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))
|
|
||||||
.overlay {
|
.overlay {
|
||||||
if isMostRecent {
|
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||||
RoundedRectangle(cornerRadius: 14)
|
.strokeBorder(.quaternary, lineWidth: 1)
|
||||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.buttonStyle(.card)
|
.buttonStyle(.card)
|
||||||
|
#elseif os(iOS)
|
||||||
|
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||||
#else
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
#endif
|
#endif
|
||||||
@@ -119,10 +146,31 @@ struct HostCardView: View {
|
|||||||
Button("Remove", role: .destructive, action: onRemove)
|
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;
|
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||||
/// tapping saves it and connects (or pairs, if the host requires it).
|
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||||
struct DiscoveredCardView: View {
|
struct DiscoveredCardView: View {
|
||||||
let discovered: DiscoveredHost
|
let discovered: DiscoveredHost
|
||||||
let isBusy: Bool
|
let isBusy: Bool
|
||||||
@@ -131,47 +179,77 @@ struct DiscoveredCardView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let m = CardMetrics.current
|
let m = CardMetrics.current
|
||||||
return Button(action: onConnect) {
|
return Button(action: onConnect) {
|
||||||
VStack(spacing: 10) {
|
HStack(spacing: m.spacing) {
|
||||||
Image(systemName: "play.display")
|
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||||
.font(.system(size: m.iconSize, weight: .light))
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.foregroundStyle(.tint)
|
|
||||||
.frame(height: m.iconBox)
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(discovered.name)
|
Text(discovered.name)
|
||||||
.font(m.nameFont)
|
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
HStack(spacing: 4) {
|
Text("\(discovered.host):\(String(discovered.port))")
|
||||||
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
.font(.geist(m.meta, relativeTo: .caption))
|
||||||
.font(.system(size: 9))
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
||||||
Text("\(discovered.host):\(String(discovered.port))")
|
HStack(spacing: 6) {
|
||||||
.font(.caption)
|
Image(systemName: discovered.requiresPairing
|
||||||
.foregroundStyle(.secondary)
|
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||||
.lineLimit(1)
|
.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(.geist(m.status, .medium, relativeTo: .caption2))
|
||||||
.font(.caption2)
|
.tracking(0.8)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.padding(m.padding)
|
||||||
.padding(.vertical, m.cardPadding)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 12)
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
.background(.regularMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14)
|
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
Color.secondary.opacity(0.25),
|
Color.secondary.opacity(0.3),
|
||||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.buttonStyle(.card)
|
.buttonStyle(.card)
|
||||||
|
#elseif os(iOS)
|
||||||
|
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||||
#else
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
#endif
|
#endif
|
||||||
.disabled(isBusy)
|
.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))
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.overlay(alignment: .topLeading) { storeBadge }
|
.overlay(alignment: .topLeading) { storeBadge }
|
||||||
Text(game.title)
|
Text(game.title)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ private struct GameCard: View {
|
|||||||
|
|
||||||
private var storeBadge: some View {
|
private var storeBadge: some View {
|
||||||
Text(game.isCustom ? "Custom" : "Steam")
|
Text(game.isCustom ? "Custom" : "Steam")
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.background(.ultraThinMaterial, in: Capsule())
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
@@ -193,7 +193,7 @@ private struct PosterImage: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Rectangle().fill(.quaternary)
|
Rectangle().fill(.quaternary)
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.headline)
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct PairSheet: View {
|
|||||||
+ "(http://<host>:3000 → Pairing). "
|
+ "(http://<host>:3000 → Pairing). "
|
||||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||||
+ "needed.")
|
+ "needed.")
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
TVFieldRow(
|
TVFieldRow(
|
||||||
@@ -59,7 +59,7 @@ struct PairSheet: View {
|
|||||||
) { editing = .clientName }
|
) { editing = .clientName }
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Text(errorText)
|
Text(errorText)
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
HStack(spacing: 32) {
|
HStack(spacing: 32) {
|
||||||
@@ -121,13 +121,13 @@ struct PairSheet: View {
|
|||||||
+ "(http://<host>:3000 → Pairing). "
|
+ "(http://<host>:3000 → Pairing). "
|
||||||
+ "Pairing verifies both sides at once — no fingerprint "
|
+ "Pairing verifies both sides at once — no fingerprint "
|
||||||
+ "comparison needed.")
|
+ "comparison needed.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Section {
|
Section {
|
||||||
Text(errorText)
|
Text(errorText)
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,20 +12,36 @@ struct PunktfunkClientApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
init() {
|
||||||
|
#if os(iOS)
|
||||||
|
// Put Geist on the navigation titles before any bar is built.
|
||||||
|
BrandTheme.apply()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup("Punktfunk") {
|
WindowGroup("Punktfunk") {
|
||||||
#if DEBUG
|
// Pin the whole app's tint to the brand purple explicitly — the asset-catalog accent
|
||||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
// resolution is environment/timing-sensitive and can fall back to system blue. Wraps the
|
||||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
// screenshot harness too, so captured screens are on-brand.
|
||||||
// the whole path is absent from Release builds.
|
Group {
|
||||||
if let scene = ScreenshotMode.requestedScene {
|
#if DEBUG
|
||||||
ScreenshotHostView(scene: scene)
|
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||||
} else {
|
// 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()
|
ContentView()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#else
|
.tint(.brand)
|
||||||
ContentView()
|
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
||||||
#endif
|
// 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
|
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||||
@@ -34,7 +50,10 @@ struct PunktfunkClientApp: App {
|
|||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Settings {
|
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()
|
SettingsView()
|
||||||
|
.tint(.brand)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ private struct ShotSettings: View {
|
|||||||
.shadow(radius: 40, y: 16)
|
.shadow(radius: 40, y: 16)
|
||||||
}
|
}
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
NavigationStack {
|
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||||
SettingsView()
|
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||||
.navigationTitle("Settings")
|
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
// the General page) instead of the bare category list.
|
||||||
}
|
SettingsView(initialCategory: .general)
|
||||||
#else
|
#else
|
||||||
NavigationStack { SettingsView() }
|
NavigationStack { SettingsView() }
|
||||||
#endif
|
#endif
|
||||||
@@ -175,10 +175,10 @@ private struct ShotHUD: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Text("⌘⎋ releases the mouse")
|
Text("⌘⎋ releases the mouse")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
Text("Press Menu to disconnect")
|
Text("Press Menu to disconnect")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
@@ -259,7 +259,7 @@ private struct ShotDesktopFrame: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "gamecontroller.fill")
|
Image(systemName: "gamecontroller.fill")
|
||||||
Text("Streaming from Battlestation")
|
Text("Streaming from Battlestation")
|
||||||
.font(.system(.callout, weight: .semibold))
|
.font(.geist(16, .semibold, relativeTo: .callout))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||||
.glassBackground(Capsule())
|
.glassBackground(Capsule())
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ final class SessionModel: ObservableObject {
|
|||||||
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
||||||
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
||||||
/// stored fingerprint is the trust decision.)
|
/// stored fingerprint is the trust decision.)
|
||||||
|
///
|
||||||
|
/// `requestAccess` is the no-PIN delegated-approval path: open an identified connect the host
|
||||||
|
/// PARKS until the operator clicks Approve in its console, then admits the SAME connection (no
|
||||||
|
/// reconnect). The handshake budget is widened to exceed the host's park window, and a
|
||||||
|
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
||||||
|
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||||
|
/// for the wait; nil = trust-on-first-use.
|
||||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||||
compositor: PunktfunkConnection.Compositor = .auto,
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
@@ -103,7 +110,8 @@ final class SessionModel: ObservableObject {
|
|||||||
hdrEnabled: Bool = true,
|
hdrEnabled: Bool = true,
|
||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
allowTofu: Bool = false,
|
allowTofu: Bool = false,
|
||||||
autoTrust: Bool = false) {
|
autoTrust: Bool = false,
|
||||||
|
requestAccess: Bool = false) {
|
||||||
guard phase == .idle else { return }
|
guard phase == .idle else { return }
|
||||||
phase = .connecting
|
phase = .connecting
|
||||||
activeHost = host
|
activeHost = host
|
||||||
@@ -121,6 +129,8 @@ final class SessionModel: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
}()
|
}()
|
||||||
let hdrCapable = hdrEnabled && displayHDR
|
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) {
|
Task.detached(priority: .userInitiated) {
|
||||||
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
// PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main
|
||||||
// actor. The persistent identity is presented on every connect so a paired
|
// actor. The persistent identity is presented on every connect so a paired
|
||||||
@@ -130,15 +140,31 @@ final class SessionModel: ObservableObject {
|
|||||||
// Advertise 10-bit + HDR10 when enabled: the host upgrades to a BT.2020 PQ Main10 stream
|
// 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
|
// 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.
|
// 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)
|
? (PunktfunkConnection.videoCap10Bit | PunktfunkConnection.videoCapHDR)
|
||||||
: 0
|
: 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(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||||
audioChannels: audioChannels, launchID: launchID) }
|
audioChannels: audioChannels, launchID: launchID,
|
||||||
|
// Delegated approval: the host holds this connect open until the operator approves
|
||||||
|
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||||||
|
// connects keep the snappy default.
|
||||||
|
timeoutMs: requestAccess ? 185_000 : 10_000) }
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// The user may have abandoned this attempt (window closed, another host
|
// The user may have abandoned this attempt (window closed, another host
|
||||||
@@ -152,7 +178,9 @@ final class SessionModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let conn):
|
case .success(let conn):
|
||||||
if pin != nil || autoTrust {
|
if pin != nil || autoTrust || requestAccess {
|
||||||
|
// requestAccess: the operator approved this device on the host, so the
|
||||||
|
// session is trusted — stream directly (the caller pins it as paired).
|
||||||
self.connection = conn
|
self.connection = conn
|
||||||
self.startStatsTimer()
|
self.startStatsTimer()
|
||||||
self.beginStreaming()
|
self.beginStreaming()
|
||||||
@@ -174,16 +202,25 @@ final class SessionModel: ObservableObject {
|
|||||||
case .failure:
|
case .failure:
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
self.activeHost = nil
|
||||||
self.errorMessage = pin != nil
|
if requestAccess {
|
||||||
? "Could not connect to \(host.displayName) — host unreachable, "
|
// The delegated-approval connect ended without being admitted: the
|
||||||
+ "not running, its identity no longer matches the pinned "
|
// operator didn't approve it before the host's park window elapsed (or
|
||||||
+ "fingerprint, or it requires pairing and no longer "
|
// the host was unreachable).
|
||||||
+ "recognizes this Mac (right-click the host card to pair "
|
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
||||||
+ "again)."
|
+ "Approve it in the host's web console (port 3000 → Pairing), then "
|
||||||
: "Could not connect to \(host.displayName) — is punktfunk-host "
|
+ "request access again — the request expires after a few minutes."
|
||||||
+ "running on \(host.address):\(host.port)? If it requires "
|
} else {
|
||||||
+ "pairing, right-click the host card and pair with its PIN "
|
self.errorMessage = pin != nil
|
||||||
+ "first."
|
? "Could not connect to \(host.displayName) — host unreachable, "
|
||||||
|
+ "not running, its identity no longer matches the pinned "
|
||||||
|
+ "fingerprint, or it requires pairing and no longer "
|
||||||
|
+ "recognizes this Mac (right-click the host card to pair "
|
||||||
|
+ "again)."
|
||||||
|
: "Could not connect to \(host.displayName) — is punktfunk-host "
|
||||||
|
+ "running on \(host.address):\(host.port)? If it requires "
|
||||||
|
+ "pairing, right-click the host card and pair with its PIN "
|
||||||
|
+ "first."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||||
// there is no scaling anywhere in the pipeline.
|
// there is no scaling anywhere in the pipeline.
|
||||||
//
|
//
|
||||||
// Navigation differs per platform: macOS uses a tabbed preferences window (the sections had
|
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||||
// outgrown one scrolling pane); iOS uses a single grouped Form; tvOS uses a focus-native
|
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||||
// pushed-picker layout. The individual sections (`streamModeSection`, `audioSection`, …) are
|
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||||
// shared across all three so a setting is defined exactly once.
|
// 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)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -21,7 +23,9 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 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.libraryEnabled) private var libraryEnabled = false
|
||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||||
@@ -32,6 +36,22 @@ struct SettingsView: View {
|
|||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
@State private var showControllerTest = false
|
@State private var showControllerTest = false
|
||||||
#endif
|
#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)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||||
@@ -39,6 +59,15 @@ struct SettingsView: View {
|
|||||||
@State private var inputDevices: [AudioDevice] = []
|
@State private var inputDevices: [AudioDevice] = []
|
||||||
#endif
|
#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 {
|
var body: some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
||||||
@@ -66,6 +95,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Form {
|
Form {
|
||||||
presenterSection
|
presenterSection
|
||||||
|
hdrSection
|
||||||
windowSection
|
windowSection
|
||||||
statisticsSection
|
statisticsSection
|
||||||
}
|
}
|
||||||
@@ -98,31 +128,124 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||||
|
|
||||||
|
AcknowledgementsView()
|
||||||
|
.tabItem { Label("About", systemImage: "info.circle") }
|
||||||
}
|
}
|
||||||
.frame(width: 480, height: 460)
|
.frame(width: 480, height: 460)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - iOS: one grouped Form
|
// MARK: - iOS / iPadOS: adaptive split view
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var iosBody: some View {
|
private var iosBody: some View {
|
||||||
Form {
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
streamModeSection
|
List(selection: $settingsSelection) {
|
||||||
audioSection
|
ForEach(SettingsCategory.allCases) { category in
|
||||||
compositorSection
|
// On iPhone the split view collapses to a push list, but a selection List
|
||||||
presenterSection
|
// draws no disclosure indicator of its own — add one in compact width for the
|
||||||
statisticsSection
|
// expected drill-in affordance. On iPad the selected row highlights instead, so
|
||||||
experimentalSection
|
// the chevron is omitted there.
|
||||||
controllersSection
|
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 {
|
.onAppear {
|
||||||
|
if horizontalSizeClass == .regular, settingsSelection == nil {
|
||||||
|
settingsSelection = .general
|
||||||
|
}
|
||||||
gamepads.refresh()
|
gamepads.refresh()
|
||||||
gamepads.startDiscovery()
|
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() }
|
.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
|
#endif
|
||||||
|
|
||||||
// MARK: - tvOS
|
// MARK: - tvOS
|
||||||
@@ -150,6 +273,10 @@ struct SettingsView: View {
|
|||||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
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 {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -180,20 +307,25 @@ struct SettingsView: View {
|
|||||||
selection: $audioChannels)
|
selection: $audioChannels)
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Compositor", options: compositors, selection: $compositor)
|
title: "Compositor", options: compositors, selection: $compositor)
|
||||||
|
#if DEBUG
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Presenter",
|
title: "Presenter (debug)",
|
||||||
options: [("Stage 1 (default)", "stage1"), ("Stage 2 (experimental)", "stage2")],
|
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||||||
selection: $presenter)
|
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 "
|
Text("The host creates a virtual output at exactly this mode — native "
|
||||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||||
+ "is honored only if available on the host.")
|
+ "is honored only if available on the host.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -213,10 +345,12 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Controller type", options: Self.padTypes, selection: $gamepadType)
|
title: "Controller type", options: Self.padTypes, selection: $gamepadType)
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 1000)
|
.frame(maxWidth: 1000)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -235,6 +369,63 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
@ViewBuilder private var streamModeSection: some View {
|
@ViewBuilder private var streamModeSection: some View {
|
||||||
Section {
|
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 {
|
HStack {
|
||||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||||
Text("×")
|
Text("×")
|
||||||
@@ -245,6 +436,7 @@ struct SettingsView: View {
|
|||||||
LabeledContent("") {
|
LabeledContent("") {
|
||||||
Button("Use this display's mode") { fillFromMainScreen() }
|
Button("Use this display's mode") { fillFromMainScreen() }
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||||
if bitrateKbps != 0 {
|
if bitrateKbps != 0 {
|
||||||
@@ -259,7 +451,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,11 +461,85 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("The host creates a virtual output at exactly this mode — "
|
Text("The host creates a virtual output at exactly this mode — "
|
||||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
@ViewBuilder private var audioSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Picker("Audio channels", selection: $audioChannels) {
|
Picker("Audio channels", selection: $audioChannels) {
|
||||||
@@ -313,11 +579,35 @@ struct SettingsView: View {
|
|||||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
Text("Host audio plays through the speaker; the microphone feeds the "
|
||||||
+ "host's virtual mic. System default follows macOS device changes. "
|
+ "host's virtual mic. System default follows macOS device changes. "
|
||||||
+ "Applies from the next session.")
|
+ "Applies from the next session.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
@ViewBuilder private var compositorSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Picker("Compositor", selection: $compositor) {
|
Picker("Compositor", selection: $compositor) {
|
||||||
@@ -333,7 +623,7 @@ struct SettingsView: View {
|
|||||||
Text("Which compositor drives the virtual output on the host. A specific "
|
Text("Which compositor drives the virtual output on the host. A specific "
|
||||||
+ "choice is honored only if that backend is available there — "
|
+ "choice is honored only if that backend is available there — "
|
||||||
+ "otherwise the host falls back to auto-detection.")
|
+ "otherwise the host falls back to auto-detection.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,26 +637,50 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
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.")
|
+ "list, so only the stream is fullscreen — not the picker.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
#endif
|
#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 {
|
@ViewBuilder private var presenterSection: some View {
|
||||||
|
#if DEBUG
|
||||||
Section {
|
Section {
|
||||||
Picker("Presenter", selection: $presenter) {
|
Picker("Presenter", selection: $presenter) {
|
||||||
Text("Stage 1 (default)").tag("stage1")
|
Text("Stage 2 (default)").tag("stage2")
|
||||||
Text("Stage 2 (experimental)").tag("stage2")
|
Text("Stage 1 (debug)").tag("stage1")
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Video presenter")
|
Text("Video presenter · debug")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Stage 1 feeds compressed video to the system display layer (known-good). "
|
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
||||||
+ "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 "
|
||||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD "
|
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
|
||||||
+ "and shortens the present tail. Applies from the next session.")
|
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
|
||||||
.font(.caption)
|
+ "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)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,7 +698,7 @@ struct SettingsView: View {
|
|||||||
Text("Statistics")
|
Text("Statistics")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(Self.statisticsFooter)
|
Text(Self.statisticsFooter)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,7 +713,7 @@ struct SettingsView: View {
|
|||||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
+ "(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 "
|
+ "The host must expose that API on the LAN with a token "
|
||||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,7 +747,7 @@ struct SettingsView: View {
|
|||||||
Text("Controllers")
|
Text("Controllers")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,13 +899,13 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.caption2)
|
.font(.geist(11, relativeTo: .caption2))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if gamepads.active?.id == controller.id {
|
if gamepads.active?.id == controller.id {
|
||||||
Text("In use")
|
Text("In use")
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.background(Capsule().fill(.green.opacity(0.2)))
|
.background(Capsule().fill(.green.opacity(0.2)))
|
||||||
@@ -613,6 +927,10 @@ struct SettingsView: View {
|
|||||||
width = Int(max(bounds.width, bounds.height))
|
width = Int(max(bounds.width, bounds.height))
|
||||||
height = Int(min(bounds.width, bounds.height))
|
height = Int(min(bounds.width, bounds.height))
|
||||||
hz = UIScreen.main.maximumFramesPerSecond
|
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
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,3 +941,52 @@ extension Double {
|
|||||||
Swift.min(Swift.max(self, lo), hi)
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
|
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
|
||||||
.font(.headline)
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
|
|
||||||
switch phase {
|
switch phase {
|
||||||
@@ -73,7 +73,7 @@ struct SpeedTestSheet: View {
|
|||||||
resultView(result)
|
resultView(result)
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
@@ -149,13 +149,13 @@ struct SpeedTestSheet: View {
|
|||||||
if let rec = Self.recommendedKbps(result) {
|
if let rec = Self.recommendedKbps(result) {
|
||||||
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
|
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
|
||||||
+ "(~70% of measured, headroom for encoder bursts).")
|
+ "(~70% of measured, headroom for encoder bursts).")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
} else {
|
} else {
|
||||||
Text("Too little data made it through to recommend a bitrate — "
|
Text("Too little data made it through to recommend a bitrate — "
|
||||||
+ "check the network and retry.")
|
+ "check the network and retry.")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,19 +69,19 @@ struct StreamHUDView: View {
|
|||||||
Text(model.mouseCaptured
|
Text(model.mouseCaptured
|
||||||
? "⌘⎋ releases the mouse"
|
? "⌘⎋ releases the mouse"
|
||||||
: "Click the stream to capture input")
|
: "Click the stream to capture input")
|
||||||
.font(.caption2)
|
.font(.geist(11, relativeTo: .caption2))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
// The client-side cursor (⌘⇧C) draws the local cursor over the stream instead of
|
// 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.
|
// capturing it — the only accurate cursor for gamescope, whose capture has none.
|
||||||
Text("⌘⇧C toggles the on-screen cursor")
|
Text("⌘⇧C toggles the on-screen cursor")
|
||||||
.font(.caption2)
|
.font(.geist(11, relativeTo: .caption2))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||||
Text(model.mouseCaptured
|
Text(model.mouseCaptured
|
||||||
? "⌘⎋ releases keyboard & mouse"
|
? "⌘⎋ releases keyboard & mouse"
|
||||||
: "⌘⎋ captures keyboard & mouse")
|
: "⌘⎋ captures keyboard & mouse")
|
||||||
.font(.caption2)
|
.font(.geist(11, relativeTo: .caption2))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#endif
|
#endif
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@@ -89,13 +89,13 @@ struct StreamHUDView: View {
|
|||||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
// 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.
|
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||||
Text("Press Menu to disconnect")
|
Text("Press Menu to disconnect")
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#else
|
#else
|
||||||
// ⌘D lives on the app's Stream menu (so it still works when the HUD is hidden);
|
// ⌘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.
|
// this button is the in-overlay, click-to-disconnect affordance.
|
||||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||||
.font(.caption)
|
.font(.geist(12, relativeTo: .caption))
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// or drops this and runs the PIN pairing ceremony instead.
|
// or drops this and runs the PIN pairing ceremony instead.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TrustCardView: View {
|
struct TrustCardView: View {
|
||||||
@@ -18,11 +19,11 @@ struct TrustCardView: View {
|
|||||||
.font(.system(size: 36, weight: .light))
|
.font(.system(size: 36, weight: .light))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
Text("Verify \(hostName)")
|
Text("Verify \(hostName)")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.geist(20, .semibold, relativeTo: .title3))
|
||||||
Text("First connection. Compare this fingerprint with the one "
|
Text("First connection. Compare this fingerprint with the one "
|
||||||
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
||||||
+ "fingerprint\u{201D}):")
|
+ "fingerprint\u{201D}):")
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Text(Self.format(fingerprint: fingerprint))
|
Text(Self.format(fingerprint: fingerprint))
|
||||||
@@ -58,7 +59,7 @@ struct TrustCardView: View {
|
|||||||
#else
|
#else
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
#endif
|
#endif
|
||||||
.font(.callout)
|
.font(.geist(16, relativeTo: .callout))
|
||||||
}
|
}
|
||||||
.padding(28)
|
.padding(28)
|
||||||
.frame(maxWidth: 440)
|
.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 speakerUID = "punktfunk.speakerUID"
|
||||||
public static let micUID = "punktfunk.micUID"
|
public static let micUID = "punktfunk.micUID"
|
||||||
public static let presenter = "punktfunk.presenter"
|
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"
|
public static let hosts = "punktfunk.hosts"
|
||||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||||
public static let cursorMode = "punktfunk.cursorMode"
|
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.
|
/// Experimental: show the host's game library (browsed over the management API). Off by default.
|
||||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
private var broken = false
|
private var broken = false
|
||||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||||
private var wasActive = false
|
private var wasActive = false
|
||||||
|
// 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
|
/// 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
|
/// 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.closeHID()
|
||||||
self.controller = c
|
self.controller = c
|
||||||
self.broken = false
|
self.broken = false
|
||||||
|
self.consecutiveFailures = 0
|
||||||
|
self.retryAfter = .distantPast
|
||||||
_ = self.openHIDIfDualSense(c)
|
_ = self.openHIDIfDualSense(c)
|
||||||
onBackend?(self.backendNote(for: 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).
|
// other pad (and for a DualSense whose HID device could not be opened).
|
||||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||||
guard !self.broken else { return }
|
guard !self.broken else { return }
|
||||||
if active, self.low == nil, self.high == nil {
|
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
|
||||||
self.setup()
|
self.setup()
|
||||||
}
|
}
|
||||||
let ok: Bool
|
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
|
// 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
|
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||||
// still holds an exclusive reference to.
|
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
|
||||||
if !ok { self.teardown() }
|
// 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)
|
low = makeMotor(haptics, .default)
|
||||||
}
|
}
|
||||||
if low == nil, high == nil {
|
if low == nil, high == nil {
|
||||||
// Haptics present but no engine could be built right now (server busy / a transient
|
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
|
||||||
// error). Do NOT latch broken — the next nonzero amplitude retries setup().
|
// NOT latch broken — back off and the next nonzero amplitude past the cooldown retries.
|
||||||
log.warning("rumble: haptics present but engine setup failed — will retry on next rumble")
|
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? {
|
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||||
|
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
||||||
|
// (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
|
// 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
|
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
|
||||||
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
||||||
@@ -338,29 +370,32 @@ public final class GamepadFeedback {
|
|||||||
// Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
|
// 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
|
// 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 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
|
let thread = Thread { [connection, flag, drainDone, weak self] in
|
||||||
while !flag.isStopped {
|
while !flag.isStopped {
|
||||||
do {
|
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)
|
self?.rumble.apply(low: r.low, high: r.high)
|
||||||
}
|
}
|
||||||
// Drain a BOUNDED burst of hidout events: only the first poll waits,
|
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||||
// and the cap + stop check keep sustained 0xCD traffic (a game writing
|
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||||
// per-frame LED/trigger reports) from starving the rumble poll above
|
|
||||||
// or blocking stop() past one cycle.
|
|
||||||
var burst = 0
|
var burst = 0
|
||||||
while burst < 64, !flag.isStopped,
|
while burst < 64, !flag.isStopped,
|
||||||
let ev = try connection.nextHidOutput(
|
let ev = try connection.nextHidOutput(timeoutMs: 0) {
|
||||||
timeoutMs: burst == 0 ? hidTimeout : 0) {
|
|
||||||
self?.render(ev)
|
self?.render(ev)
|
||||||
burst += 1
|
burst += 1
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
break // .closed (or fatal) — the session is over
|
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()
|
drainDone.signal()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,13 @@ public final class InputCapture {
|
|||||||
previous.onPreempted?()
|
previous.onPreempted?()
|
||||||
}
|
}
|
||||||
Self.activeCapture = self
|
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) }
|
if let keyboard = GCKeyboard.coalesced { attach(keyboard: keyboard) }
|
||||||
observers.append(NotificationCenter.default.addObserver(
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
forName: .GCMouseDidConnect, object: nil, queue: .main
|
forName: .GCMouseDidConnect, object: nil, queue: .main
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Open-source license / attribution text bundled with PunktfunkKit (see `Resources/`).
|
||||||
|
///
|
||||||
|
/// Exposed from the kit so the app shell can show an Acknowledgements screen. The text files are
|
||||||
|
/// bundled as SwiftPM resources and read via `Bundle.module`, which works both for `swift build`
|
||||||
|
/// and for the Xcode app (it links the PunktfunkKit product, so the resource bundle rides along).
|
||||||
|
public enum Licenses {
|
||||||
|
private static func resource(_ name: String) -> String {
|
||||||
|
guard let url = Bundle.module.url(forResource: name, withExtension: "txt"),
|
||||||
|
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
else { return "" }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// punktfunk's own license — MIT OR Apache-2.0, at your option.
|
||||||
|
public static var appLicense: String {
|
||||||
|
let mit = resource("LICENSE-MIT")
|
||||||
|
let apache = resource("LICENSE-APACHE")
|
||||||
|
if mit.isEmpty && apache.isEmpty {
|
||||||
|
return "punktfunk is licensed under MIT OR Apache-2.0, at your option."
|
||||||
|
}
|
||||||
|
return "punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n"
|
||||||
|
+ "================================ MIT ================================\n\n"
|
||||||
|
+ mit
|
||||||
|
+ "\n\n============================== Apache-2.0 ==============================\n\n"
|
||||||
|
+ 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
|
// Stage-2 presenter, present half: draw a decoded NV12 / P010 / 4:4:4 CVPixelBuffer into a CAMetalLayer
|
||||||
// drawable with a BT.709 YUV→RGB shader. The display link (owned by the hosting view) drives
|
// drawable with a Y′CbCr→RGB shader. The hosting view's CADisplayLink drives `render` once per vsync
|
||||||
// `render` once per vsync with the target present time, so a present can finally be stamped and
|
// (via Stage2Pipeline.renderTick) with the target present time, so a present can be stamped and the
|
||||||
// the present tail hand-paced. See docs apple-stage2-presenter.md.
|
// present tail hand-paced. See docs apple-stage2-presenter.md.
|
||||||
//
|
//
|
||||||
// Main-thread only: created during view setup, `render` called from the view's CADisplayLink
|
// 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.
|
// (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)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import CoreVideo
|
import CoreVideo
|
||||||
import Metal
|
import Metal
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
import os
|
||||||
|
|
||||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a
|
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||||
/// 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
|
/// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the
|
||||||
/// for now — matches the host; 10-bit/HDR + other matrices are a later tie-in.)
|
/// 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 = """
|
private let shaderSource = """
|
||||||
#include <metal_stdlib>
|
#include <metal_stdlib>
|
||||||
using namespace metal;
|
using namespace metal;
|
||||||
@@ -30,11 +44,46 @@ vertex VOut pf_vtx(uint vid [[vertex_id]]) {
|
|||||||
return o;
|
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]],
|
fragment float4 pf_frag(VOut in [[stage_in]],
|
||||||
texture2d<float> lumaTex [[texture(0)]],
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
texture2d<float> chromaTex [[texture(1)]]) {
|
texture2d<float> chromaTex [[texture(1)]]) {
|
||||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
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;
|
float2 c = chromaTex.sample(s, in.uv).rg;
|
||||||
// BT.709, 8-bit limited (video) range → full-range RGB.
|
// BT.709, 8-bit limited (video) range → full-range RGB.
|
||||||
y = (y - 16.0/255.0) * (255.0/219.0);
|
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);
|
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
|
// 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
|
// matrix to get PQ-encoded R′G′B′ and output it as-is — the CAMetalLayer's itur_2100_PQ colour space
|
||||||
// space + EDR tells the compositor the samples are PQ, so it does the PQ→display mapping. No EOTF
|
// + edrMetadata tell the compositor the samples are PQ, so it does the PQ→display tone-map. No EOTF
|
||||||
// here (matching the host, which emitted BT.2020 PQ). P010 stores the 10-bit code in the high bits
|
// here. P010/x444 store the 10-bit code in the high bits of each 16-bit sample, so an .r16Unorm sample
|
||||||
// of each 16-bit sample, so an .r16Unorm sample reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
|
// reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
|
||||||
fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||||
texture2d<float> lumaTex [[texture(0)]],
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
texture2d<float> chromaTex [[texture(1)]]) {
|
texture2d<float> chromaTex [[texture(1)]]) {
|
||||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
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;
|
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);
|
y = (y - 64.0/1023.0) * (1023.0/876.0);
|
||||||
float u = (c.x - 512.0/1023.0) * (1023.0/896.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);
|
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 device: MTLDevice
|
||||||
private let queue: MTLCommandQueue
|
private let queue: MTLCommandQueue
|
||||||
/// SDR (BT.709 8-bit NV12 → bgra8) and HDR (BT.2020 PQ 10-bit P010 → rgba16Float) pipelines.
|
/// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per
|
||||||
/// Selected per frame by `render`; the layer is reconfigured when the mode flips (HDR toggle).
|
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
|
||||||
private let pipelineSDR: MTLRenderPipelineState
|
private let pipelineSDR: MTLRenderPipelineState
|
||||||
private let pipelineHDR: MTLRenderPipelineState
|
private let pipelineHDR: MTLRenderPipelineState
|
||||||
private var textureCache: CVMetalTextureCache?
|
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.
|
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
||||||
public init?() {
|
/// 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(),
|
guard let device = MTLCreateSystemDefaultDevice(),
|
||||||
let queue = device.makeCommandQueue()
|
let queue = device.makeCommandQueue()
|
||||||
else { return nil }
|
else { return nil }
|
||||||
self.device = device
|
let pipelineSDR: MTLRenderPipelineState
|
||||||
self.queue = queue
|
let pipelineHDR: MTLRenderPipelineState
|
||||||
do {
|
do {
|
||||||
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
||||||
let vtx = library.makeFunction(name: "pf_vtx")
|
let vtx = library.makeFunction(name: "pf_vtx")
|
||||||
@@ -105,76 +167,148 @@ public final class MetalVideoPresenter {
|
|||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &textureCache)
|
var cache: CVMetalTextureCache?
|
||||||
guard textureCache != nil else { return nil }
|
CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &cache)
|
||||||
|
guard let textureCache = cache else { return nil }
|
||||||
|
|
||||||
let layer = CAMetalLayer()
|
let layer = CAMetalLayer()
|
||||||
layer.device = device
|
layer.device = device
|
||||||
layer.pixelFormat = .bgra8Unorm
|
layer.pixelFormat = .bgra8Unorm
|
||||||
layer.framebufferOnly = true
|
layer.framebufferOnly = true
|
||||||
layer.isOpaque = 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)
|
#if os(macOS)
|
||||||
// The display link already paces exactly one present per vsync. Leaving the layer's
|
// The display link already paces exactly one present per vsync. Leaving the layer's own vsync
|
||||||
// own vsync wait on means `commandBuffer.present` ALSO blocks for the hardware vsync,
|
// wait on means `commandBuffer.present` ALSO blocks for the hardware vsync, so `nextDrawable()`
|
||||||
// so `nextDrawable()` stalls the MAIN thread until a drawable frees — windowed, the
|
// stalls the MAIN thread until a drawable frees — windowed, the WindowServer's looser
|
||||||
// WindowServer's looser compositing hides it; FULLSCREEN's tighter, more-direct path
|
// compositing hides it; FULLSCREEN's tighter path serializes the main thread to the display and
|
||||||
// serializes the main thread to the display and the stall surfaces as bad judder.
|
// the stall surfaces as bad judder. Disabling the layer-level sync lets present return promptly
|
||||||
// Disabling the layer-level sync lets present return promptly (the display link is the
|
// (the display link is the pacing source) — the fix for the fullscreen stutter. macOS-only.
|
||||||
// pacing source), which is what fixes the fullscreen stutter. macOS-only property.
|
|
||||||
layer.displaySyncEnabled = false
|
layer.displaySyncEnabled = false
|
||||||
#endif
|
#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
|
self.layer = layer
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track the stream mode (the host can Reconfigure mid-stream). Size is in pixels.
|
/// Configure the layer + active pipeline for an SDR or HDR session. MAIN THREAD ONLY. Called once at
|
||||||
public func setDrawableSize(_ size: CGSize) {
|
/// session start and again per-frame from `render` (idempotent — the guard makes a same-state call a
|
||||||
guard size.width > 0, size.height > 0 else { return }
|
/// no-op), so a mid-session HDR toggle (the host re-inits its encoder; the decoded `frame.isHDR`
|
||||||
if layer.drawableSize != size { layer.drawableSize = size }
|
/// 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) {
|
||||||
/// 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) {
|
|
||||||
guard hdr != hdrActive else { return }
|
guard hdr != hdrActive else { return }
|
||||||
hdrActive = hdr
|
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 {
|
if hdr {
|
||||||
layer.pixelFormat = .rgba16Float
|
layer.pixelFormat = .rgba16Float
|
||||||
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||||
#if os(macOS)
|
#if !os(tvOS)
|
||||||
layer.wantsExtendedDynamicRangeContent = true
|
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
|
#endif
|
||||||
} else {
|
} 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.pixelFormat = .bgra8Unorm
|
||||||
layer.colorspace = nil
|
layer.colorspace = nil
|
||||||
#if os(macOS)
|
#if !os(tvOS)
|
||||||
layer.wantsExtendedDynamicRangeContent = false
|
layer.wantsExtendedDynamicRangeContent = false
|
||||||
|
layer.edrMetadata = nil
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one decoded frame to the next drawable and present it. `isHDR` selects the 10-bit
|
#if !os(tvOS)
|
||||||
/// BT.2020 PQ path (P010 input) vs the 8-bit BT.709 path (NV12 input). Returns true on success;
|
private func makeEDR(_ meta: PunktfunkConnection.HdrMeta?) -> CAEDRMetadata {
|
||||||
/// false when there's no drawable yet, a texture couldn't be made, or Metal errored — the
|
CAEDRMetadata.hdr10(
|
||||||
/// caller then doesn't stamp a present for this frame.
|
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
|
@discardableResult
|
||||||
public func render(_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false) -> Bool {
|
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)
|
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
|
// P010/x444 store 10-bit luma/chroma in 16-bit samples → R16/RG16; NV12/444v is 8-bit → R8/RG8.
|
||||||
let chromaFmt: MTLPixelFormat = isHDR ? .rg16Unorm : .rg8Unorm
|
// 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,
|
guard let textureCache,
|
||||||
let luma = makeTexture(pixelBuffer, plane: 0, format: lumaFmt, cache: textureCache),
|
let luma = makeTexture(
|
||||||
let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache)
|
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
|
||||||
|
let chroma = makeTexture(
|
||||||
|
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
// The hosting view owns drawableSize (aspect-fit to its bounds); skip until it's laid
|
// Size the drawable to the decoded frame so the fullscreen triangle samples 1:1 (pixel-exact);
|
||||||
// out. The fullscreen triangle scales the decoded texture to fill the drawable.
|
// the layer's contentsGravity then scales it to the on-screen bounds via the system compositor
|
||||||
guard layer.drawableSize.width > 0, layer.drawableSize.height > 0,
|
// (matching stage-1). drawableSize does NOT track bounds (defaults to 0), so set it BEFORE
|
||||||
let drawable = layer.nextDrawable(),
|
// 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()
|
let commandBuffer = queue.makeCommandBuffer()
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
@@ -186,24 +320,23 @@ public final class MetalVideoPresenter {
|
|||||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
encoder.setRenderPipelineState(isHDR ? pipelineHDR : pipelineSDR)
|
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||||
encoder.endEncoding()
|
encoder.endEncoding()
|
||||||
commandBuffer.present(drawable) // present at the next vsync — lowest latency
|
commandBuffer.present(drawable) // present at the next vsync — lowest latency
|
||||||
// Hold the CVMetalTextures + the source pixel buffer (its IOSurface) alive until the GPU
|
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
|
||||||
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
// sampling — releasing them at scope exit could free the backing mid-read.
|
||||||
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
||||||
commandBuffer.commit()
|
commandBuffer.commit()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past
|
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the
|
||||||
/// the draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
/// draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
||||||
private func makeTexture(
|
private func makeTexture(
|
||||||
_ pixelBuffer: CVPixelBuffer, plane: Int, format: MTLPixelFormat,
|
_ pixelBuffer: CVPixelBuffer, plane: Int, format: MTLPixelFormat, cache: CVMetalTextureCache
|
||||||
cache: CVMetalTextureCache
|
|
||||||
) -> CVMetalTexture? {
|
) -> CVMetalTexture? {
|
||||||
let w = CVPixelBufferGetWidthOfPlane(pixelBuffer, plane)
|
let w = CVPixelBufferGetWidthOfPlane(pixelBuffer, plane)
|
||||||
let h = CVPixelBufferGetHeightOfPlane(pixelBuffer, plane)
|
let h = CVPixelBufferGetHeightOfPlane(pixelBuffer, plane)
|
||||||
@@ -215,5 +348,16 @@ public final class MetalVideoPresenter {
|
|||||||
else { return nil }
|
else { return nil }
|
||||||
return cvTexture
|
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
|
#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 dualSense = 2
|
||||||
case xboxOne = 3
|
case xboxOne = 3
|
||||||
case dualShock4 = 4
|
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
|
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||||
/// `GamepadPref::from_name`.
|
/// `GamepadPref::from_name`.
|
||||||
@@ -192,6 +197,8 @@ public final class PunktfunkConnection {
|
|||||||
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
||||||
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
||||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||||
|
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||||
|
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +238,13 @@ public final class PunktfunkConnection {
|
|||||||
public private(set) var colorFullRange: Bool = false
|
public private(set) var colorFullRange: Bool = false
|
||||||
/// Encoded bit depth (8 or 10).
|
/// Encoded bit depth (8 or 10).
|
||||||
public private(set) var bitDepth: UInt8 = 8
|
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
|
/// True when the negotiated stream is HDR (PQ or HLG transfer) — drive an HDR present path and
|
||||||
/// drain `nextHdrMeta`.
|
/// drain `nextHdrMeta`.
|
||||||
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
|
public var isHDR: Bool { colorTransfer == 16 || colorTransfer == 18 }
|
||||||
@@ -327,6 +341,9 @@ public final class PunktfunkConnection {
|
|||||||
colorMatrix = mtx
|
colorMatrix = mtx
|
||||||
colorFullRange = fullRange != 0
|
colorFullRange = fullRange != 0
|
||||||
bitDepth = depth
|
bitDepth = depth
|
||||||
|
var cf: UInt8 = 1
|
||||||
|
_ = punktfunk_connection_chroma_format(handle, &cf)
|
||||||
|
chromaFormat = cf
|
||||||
var ac: UInt8 = 2
|
var ac: UInt8 = 2
|
||||||
_ = punktfunk_connection_audio_channels(handle, &ac)
|
_ = punktfunk_connection_audio_channels(handle, &ac)
|
||||||
resolvedAudioChannels = ac
|
resolvedAudioChannels = ac
|
||||||
@@ -598,6 +615,10 @@ public final class PunktfunkConnection {
|
|||||||
public static let videoCap10Bit: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_10BIT)
|
public static let videoCap10Bit: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_10BIT)
|
||||||
/// Video-capability bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
/// Video-capability bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||||
public static let videoCapHDR: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_HDR)
|
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
|
/// 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,
|
/// 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.
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or Derivative
|
||||||
|
Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 unom
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 unom
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -177,6 +177,16 @@ public final class SessionAudio {
|
|||||||
private var playbackEngine: AVAudioEngine?
|
private var playbackEngine: AVAudioEngine?
|
||||||
private var captureEngine: AVAudioEngine?
|
private var captureEngine: AVAudioEngine?
|
||||||
private var drainStarted = false
|
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) {
|
public init(connection: PunktfunkConnection) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -189,37 +199,60 @@ public final class SessionAudio {
|
|||||||
flag.stop()
|
flag.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system
|
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||||
/// default device; on iOS the UIDs are ignored entirely (routes are
|
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
|
||||||
/// AVAudioSession-managed). Main thread (engine setup); returns after the engines
|
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
|
||||||
/// start — the mic may start slightly later if the permission prompt is pending.
|
/// 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) {
|
public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||||
#if os(iOS)
|
#if os(macOS)
|
||||||
// Route + policy live in the session, not per-engine: stereo playback, mic
|
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||||
// capture when enabled, Bluetooth allowed. Failure is non-fatal (defaults).
|
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()
|
let session = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
|
#if os(iOS)
|
||||||
if micEnabled {
|
if micEnabled {
|
||||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone
|
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
|
||||||
// EARPIECE; only affects the built-in route (headphones/BT still win).
|
// affects the built-in route (headphones/BT still win).
|
||||||
try session.setCategory(
|
try session.setCategory(
|
||||||
.playAndRecord, mode: .default,
|
.playAndRecord, mode: .default,
|
||||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||||
} else {
|
} else {
|
||||||
try session.setCategory(.playback, mode: .default)
|
try session.setCategory(.playback, mode: .default)
|
||||||
}
|
}
|
||||||
|
#else // tvOS — no app-accessible mic
|
||||||
|
try session.setCategory(.playback, mode: .default)
|
||||||
|
#endif
|
||||||
try session.setActive(true)
|
try session.setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
#elseif os(tvOS)
|
}
|
||||||
do {
|
#endif
|
||||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||||
} catch {
|
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||||
}
|
|
||||||
#endif
|
|
||||||
startPlayback(speakerUID: speakerUID)
|
startPlayback(speakerUID: speakerUID)
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// No app-accessible microphone input on tvOS — playback only.
|
// No app-accessible microphone input on tvOS — playback only.
|
||||||
@@ -258,19 +291,24 @@ public final class SessionAudio {
|
|||||||
capture.stop()
|
capture.stop()
|
||||||
}
|
}
|
||||||
playback?.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 {
|
if wasDraining {
|
||||||
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
|
_ = 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)
|
// MARK: - Playback (host → speaker)
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
// Stage-2 presenter orchestrator: a pump thread pulls AUs → VideoDecoder; the decoder's async
|
// Stage-2 presenter orchestrator: a pump thread pulls AUs → VideoDecoder; the decoder's async output
|
||||||
// output drops the newest decoded frame into a 1-slot ring; the hosting view's display link
|
// drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick`
|
||||||
// calls `renderTick` once per vsync to draw + present the newest ready frame and stamp
|
// once per vsync to draw + present the newest ready frame and stamp capture→present. Mirrors
|
||||||
// capture→present. Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
|
// 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`
|
// 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).
|
// `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). Only the ring (lock-guarded)
|
||||||
// Only the ring + decoder cross threads and both are internally locked.
|
// and the decoder/presenter (internally locked / main-hopped) cross threads.
|
||||||
|
|
||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view
|
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view directly
|
||||||
/// directly makes a `view → link → view` cycle that only `invalidate()` breaks — if a teardown
|
/// makes a `view → link → view` cycle that only `invalidate()` breaks — if a teardown is ever missed
|
||||||
/// is ever missed the view leaks and keeps ticking. This proxy holds the handler weakly, so the
|
/// the view leaks and keeps ticking. This proxy holds the handler weakly, so the view can deallocate
|
||||||
/// view can deallocate and its `deinit` invalidate the link.
|
/// and its `deinit` invalidate the link.
|
||||||
public final class DisplayLinkProxy: NSObject {
|
public final class DisplayLinkProxy: NSObject {
|
||||||
private let onTick: (CADisplayLink) -> Void
|
private let onTick: (CADisplayLink) -> Void
|
||||||
public init(_ onTick: @escaping (CADisplayLink) -> Void) { self.onTick = onTick }
|
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() }
|
func cancel() { lock.lock(); live = false; lock.unlock() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Throttled host keyframe requests for decode recovery. The decoder's async error callback
|
/// Throttled host keyframe requests for decode recovery. The decoder's async error callback (a VT
|
||||||
/// (a VT thread) and the pump thread (a submit failure) both signal a wedge; this coalesces
|
/// thread) and the pump thread (a submit failure) both signal a wedge; this coalesces them so the
|
||||||
/// them so the control stream isn't flooded while the decode stays stalled for several frames
|
/// control stream isn't flooded while the decode stays stalled for several frames until the requested
|
||||||
/// until the requested IDR lands. Bound to the live connection in `start`, unbound in `stop`.
|
/// IDR lands. Bound to the live connection in `start`, unbound in `stop`.
|
||||||
private final class KeyframeRecovery: @unchecked Sendable {
|
private final class KeyframeRecovery: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var connection: PunktfunkConnection?
|
private var connection: PunktfunkConnection?
|
||||||
@@ -60,7 +60,7 @@ private final class KeyframeRecovery: @unchecked Sendable {
|
|||||||
func request() {
|
func request() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
let now = DispatchTime.now().uptimeNanoseconds
|
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 }
|
if due { lastNs = now }
|
||||||
let conn = due ? connection : nil
|
let conn = due ? connection : nil
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
@@ -76,30 +76,36 @@ public final class Stage2Pipeline {
|
|||||||
private let recovery = KeyframeRecovery()
|
private let recovery = KeyframeRecovery()
|
||||||
private var token = PumpToken()
|
private var token = PumpToken()
|
||||||
private var offsetNs: Int64 = 0
|
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
|
/// The Metal layer the hosting view installs + sizes.
|
||||||
/// unavailable so the caller can fall back to stage-1.
|
|
||||||
public var layer: CAMetalLayer { presenter.layer }
|
public var layer: CAMetalLayer { presenter.layer }
|
||||||
|
|
||||||
/// `presentMeter` records capture→present (the glass-to-glass term). Returns nil if Metal
|
/// `presentMeter` records capture→present (the glass-to-glass term). Returns nil if Metal can't be
|
||||||
/// can't be set up (headless / no GPU) — caller falls back to the stage-1 presenter.
|
/// set up (headless / no GPU) — caller falls back to the stage-1 presenter.
|
||||||
public init?(presentMeter: LatencyMeter) {
|
public init?(presentMeter: LatencyMeter) {
|
||||||
guard let presenter = MetalVideoPresenter() else { return nil }
|
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
self.presentMeter = presentMeter
|
self.presentMeter = presentMeter
|
||||||
let ring = ring
|
let ring = ring
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
self.decoder = VideoDecoder(
|
self.decoder = VideoDecoder(
|
||||||
onDecoded: { ring.submit($0) },
|
onDecoded: { ring.submit($0) },
|
||||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump
|
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
||||||
// resets to re-gate on the next IDR, and we ask the host to send one now (infinite
|
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
||||||
// GOP — it wouldn't otherwise come soon). Throttled in KeyframeRecovery.
|
// otherwise come soon). Throttled in KeyframeRecovery.
|
||||||
onDecodeError: { _ in recovery.request() })
|
onDecodeError: { _ in recovery.request() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start pulling AUs into the decoder. `onFrame` fires per AU at receipt (capture→client
|
/// 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)
|
/// meter, exactly as stage-1); `onSessionEnd` on close. `clockOffsetNs` (host minus client) makes the
|
||||||
/// makes the present stamp cross-machine valid.
|
/// present stamp cross-machine valid.
|
||||||
public func start(
|
public func start(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
@@ -108,43 +114,70 @@ public final class Stage2Pipeline {
|
|||||||
offsetNs = connection.clockOffsetNs
|
offsetNs = connection.clockOffsetNs
|
||||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||||
token = PumpToken() // fresh token per start — cancel is permanent (like StreamPump)
|
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 token = token
|
||||||
let decoder = decoder
|
let decoder = decoder
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
|
let presenter = presenter
|
||||||
|
let pumpStopped = pumpStopped
|
||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
|
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
var lastFramesDropped = connection.framesDropped()
|
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 {
|
while token.isLive {
|
||||||
do {
|
do {
|
||||||
// Loss recovery (the primary recovery path). The reassembler drops unrecoverable
|
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||||
// AUs (framesDropped) and the decoder then conceals the reference-missing delta
|
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||||
// frames that follow — often rendering them WITHOUT an error callback — so the
|
// so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh
|
||||||
// onDecodeError trigger rarely fires after a real network blip. Ask the host for
|
// IDR re-anchors decode.
|
||||||
// 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.
|
|
||||||
let dropped = connection.framesDropped()
|
let dropped = connection.framesDropped()
|
||||||
if dropped > lastFramesDropped {
|
if dropped > lastFramesDropped {
|
||||||
lastFramesDropped = dropped
|
lastFramesDropped = dropped
|
||||||
recovery.request()
|
awaitingIDR = true
|
||||||
}
|
}
|
||||||
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
|
if awaitingIDR { recovery.request() }
|
||||||
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
|
// Drain HDR mastering metadata (0xCE) and hand it to the PRESENTER (→ CAEDRMetadata).
|
||||||
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
|
||||||
decoder.setHdrMeta(meta)
|
// 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 }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
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 }
|
guard let f = format, token.isLive else { continue }
|
||||||
if !decoder.decode(au: au, format: f) {
|
if decoder.decode(au: au, format: f) {
|
||||||
// Submit/decoder error: drop the session and re-gate on the next IDR's
|
decodeFailRun = 0
|
||||||
// in-band parameter sets (a delta frame can't recover) — stage-1's policy
|
} else {
|
||||||
// — and ask the host for that IDR now (infinite GOP; throttled).
|
// 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()
|
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 {
|
} catch {
|
||||||
if token.isLive { onSessionEnd?() }
|
if token.isLive { onSessionEnd?() }
|
||||||
@@ -154,27 +187,30 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
thread.name = "punktfunk-stage2-pump"
|
thread.name = "punktfunk-stage2-pump"
|
||||||
thread.qualityOfService = .userInteractive
|
thread.qualityOfService = .userInteractive
|
||||||
|
pumpJoinable = true
|
||||||
thread.start()
|
thread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MAIN thread, once per vsync. Present the newest ready frame (if any) and stamp
|
/// MAIN thread, once per vsync. Present the newest ready frame (if any) and stamp capture→present at
|
||||||
/// capture→present at `targetPresentNs` — the display link's target present instant, already
|
/// `targetPresentNs` — the display link's target present instant, already converted to
|
||||||
/// converted to `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`).
|
/// `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`).
|
||||||
public func renderTick(targetPresentNs: Int64) {
|
public func renderTick(targetPresentNs: Int64) {
|
||||||
guard let frame = ring.take() else { return }
|
guard let frame = ring.take() else { return }
|
||||||
guard presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) else { return }
|
guard presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) else { return }
|
||||||
presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs)
|
presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MAIN thread. Keep the drawable matched to the negotiated mode (host can Reconfigure).
|
/// Stop the pump (≤ one poll timeout) and drop the decode session. MAIN THREAD; idempotent. Does not
|
||||||
public func setDrawableSize(_ size: CGSize) {
|
/// close the connection. A restart needs a fresh Stage2Pipeline (cancel is permanent).
|
||||||
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).
|
|
||||||
public func stop() {
|
public func stop() {
|
||||||
token.cancel()
|
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()
|
decoder.reset()
|
||||||
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
||||||
}
|
}
|
||||||
@@ -182,8 +218,8 @@ public final class Stage2Pipeline {
|
|||||||
deinit { token.cancel() }
|
deinit { token.cancel() }
|
||||||
|
|
||||||
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
/// 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
|
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target
|
||||||
/// target present time (when the frame is actually on glass), not the moment we drew.
|
/// present time (when the frame is actually on glass), not the moment we drew.
|
||||||
public static func realtimeNs(forDisplayLinkTimestamp t: CFTimeInterval) -> Int64 {
|
public static func realtimeNs(forDisplayLinkTimestamp t: CFTimeInterval) -> Int64 {
|
||||||
let caNow = CACurrentMediaTime()
|
let caNow = CACurrentMediaTime()
|
||||||
var ts = timespec()
|
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 AVFoundation
|
||||||
import Foundation
|
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
|
/// 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().
|
/// its own token, so it can never be revived by a newer start().
|
||||||
@@ -47,44 +50,74 @@ final class StreamPump {
|
|||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
var lastKeyframeRequest = Date.distantPast
|
var lastKeyframeRequest = Date.distantPast
|
||||||
var lastFramesDropped = connection.framesDropped()
|
var lastFramesDropped = connection.framesDropped()
|
||||||
// Coalesced host keyframe request: the decode stays wedged for several frames until
|
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
||||||
// the IDR lands, so requesting on every frame would flood the control stream.
|
// 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() {
|
func requestKeyframeThrottled() {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
if now.timeIntervalSince(lastKeyframeRequest) > 0.1 {
|
||||||
connection.requestKeyframe()
|
connection.requestKeyframe()
|
||||||
lastKeyframeRequest = now
|
lastKeyframeRequest = now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while token.isLive {
|
while token.isLive {
|
||||||
do {
|
do {
|
||||||
// Loss recovery (the primary recovery path). Under the host's infinite GOP the
|
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||||
// only recovery keyframe is one we request. The reassembler drops unrecoverable
|
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||||
// AUs (framesDropped); the decoder then *conceals* the reference-missing delta
|
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||||
// frames that follow — a frozen / garbage picture, WITHOUT flipping the layer to
|
// frozen / garbage picture that never flips the layer to .failed — so key off the
|
||||||
// .failed — so the .failed check below rarely fires after a real network blip.
|
// drop count climbing, then keep asking (awaitingIDR) until an IDR lands. Polled
|
||||||
// Ask the host for a fresh IDR whenever the drop count climbs. Polled every
|
// every iteration so a total-loss drought still recovers when packets resume.
|
||||||
// iteration (not just per AU) so a total-loss drought still recovers the moment
|
|
||||||
// packets resume and the reassembler counts the gap.
|
|
||||||
let dropped = connection.framesDropped()
|
let dropped = connection.framesDropped()
|
||||||
if dropped > lastFramesDropped {
|
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
|
lastFramesDropped = dropped
|
||||||
requestKeyframeThrottled()
|
awaitingIDR = true
|
||||||
}
|
}
|
||||||
|
if awaitingIDR { requestKeyframeThrottled() }
|
||||||
|
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
let idrFormat = AnnexB.formatDescription(fromIDR: au.data)
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
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
|
// 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
|
// IDR): flush and, unless THIS AU is the recovering IDR (re-anchored above),
|
||||||
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled:
|
// re-gate on the next in-band parameter sets and keep asking — enqueuing a
|
||||||
// the layer stays .failed across several polls until the IDR lands.
|
// delta into a failed layer can't recover it.
|
||||||
|
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
||||||
layer.flush()
|
layer.flush()
|
||||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
if idrFormat == nil {
|
||||||
requestKeyframeThrottled()
|
format = nil
|
||||||
|
awaitingIDR = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
wasFailed = failed
|
||||||
guard let f = format,
|
guard let f = format,
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||||
token.isLive // don't enqueue a stale frame after a restart
|
token.isLive // don't enqueue a stale frame after a restart
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
public final class StreamLayerView: NSView {
|
public final class StreamLayerView: NSView {
|
||||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||||
private var pump: StreamPump?
|
private var pump: StreamPump?
|
||||||
/// Stage-2 presenter (opt-in via `punktfunk.presenter`): a CAMetalLayer sublayer driven by a
|
/// Stage-2 presenter (default): a CAMetalLayer sublayer driven by a display link instead of the
|
||||||
/// display link instead of the StreamPump → displayLayer path. nil = stage-1 (default).
|
/// StreamPump → displayLayer path. nil = stage-1 (Metal-unavailable fallback / DEBUG toggle).
|
||||||
var presentMeter: LatencyMeter?
|
var presentMeter: LatencyMeter?
|
||||||
private var stage2: Stage2Pipeline?
|
private var stage2: Stage2Pipeline?
|
||||||
private var stage2Link: CADisplayLink?
|
private var stage2Link: CADisplayLink?
|
||||||
@@ -245,6 +245,15 @@ public final class StreamLayerView: NSView {
|
|||||||
layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view
|
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
|
// MARK: - Capture state machine
|
||||||
|
|
||||||
/// Clicking into the video engages capture; that click is local (engagement), so
|
/// Clicking into the video engages capture; that click is local (engagement), so
|
||||||
@@ -549,10 +558,17 @@ public final class StreamLayerView: NSView {
|
|||||||
cursorVisible = false
|
cursorVisible = false
|
||||||
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
||||||
|
|
||||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||||
// (`punktfunk.presenter == "stage2"`) takes explicit VTDecompressionSession decode + a
|
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||||
// CAMetalLayer/display-link present; it falls back here if Metal can't be set up.
|
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is
|
||||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
// 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 meter = presentMeter,
|
||||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -593,9 +609,11 @@ public final class StreamLayerView: NSView {
|
|||||||
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
|
||||||
/// so this is usually the full bounds; it letterboxes a resized window). drawableSize is the
|
/// mode, so this is usually the full bounds; it letterboxes a resized window). Only the layer
|
||||||
/// layer's pixel size — the fullscreen-triangle shader scales the decoded texture to fill it.
|
/// 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() {
|
private func layoutMetalLayer() {
|
||||||
guard let metalLayer, let connection else { return }
|
guard let metalLayer, let connection else { return }
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
@@ -604,14 +622,12 @@ public final class StreamLayerView: NSView {
|
|||||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||||
insideRect: bounds)
|
insideRect: bounds)
|
||||||
: bounds
|
: bounds
|
||||||
let scale = window?.backingScaleFactor ?? 1
|
|
||||||
// No implicit resize animation; refresh contentsScale on a retina↔non-retina move.
|
// No implicit resize animation; refresh contentsScale on a retina↔non-retina move.
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
metalLayer.contentsScale = scale
|
metalLayer.contentsScale = window?.backingScaleFactor ?? 1
|
||||||
metalLayer.frame = fit
|
metalLayer.frame = fit
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewDidChangeBackingProperties() {
|
public override func viewDidChangeBackingProperties() {
|
||||||
@@ -622,7 +638,7 @@ public final class StreamLayerView: NSView {
|
|||||||
private func teardownStage2() {
|
private func teardownStage2() {
|
||||||
stage2Link?.invalidate()
|
stage2Link?.invalidate()
|
||||||
stage2Link = nil
|
stage2Link = nil
|
||||||
stage2?.stop()
|
stage2?.stop() // stops the pump (synchronous join) + drops the decode session
|
||||||
stage2 = nil
|
stage2 = nil
|
||||||
metalLayer?.removeFromSuperlayer()
|
metalLayer?.removeFromSuperlayer()
|
||||||
metalLayer = nil
|
metalLayer = nil
|
||||||
|
|||||||
@@ -11,13 +11,18 @@
|
|||||||
// host mode, so the host's rescale is the identity).
|
// 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
|
// 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
|
// (full-screen + frontmost iPad, and the user hasn't disabled pointer capture in Settings —
|
||||||
// the cursor — the gaming-grade path. When it CAN'T lock (Stage Manager, not frontmost,
|
// see PointerLockChain, which steers the lock request through SwiftUI's hosting controllers)
|
||||||
// iPhone) the system shows its own cursor and routes the mouse through UIKit's pointer path:
|
// GCMouse delivers raw relative deltas and the system hides the cursor — the gaming-grade path.
|
||||||
// hover + indirect-pointer touches, which we forward as ABSOLUTE cursor position (+ buttons)
|
// InputCapture handles EVERY connected mouse (GCMouse.mice), not just the current one, so a
|
||||||
// so the host cursor tracks the visible local one. We never forward an indirect pointer as a
|
// trackpad + a second pointer (e.g. a Universal Control mouse) both drive. When the scene CAN'T
|
||||||
// touch — doing so hid the cursor and made the host see taps instead of a moving mouse.
|
// lock (Stage Manager, not frontmost, iPhone, capture disabled) the system shows its own cursor
|
||||||
// GCMouse is gated off whenever the lock isn't held so the two paths can't double-send.
|
// 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
|
// Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming
|
||||||
// starts, ⌘⎋ toggles (detected from the HID stream; there is no NSEvent monitor here).
|
// 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?
|
public private(set) var connection: PunktfunkConnection?
|
||||||
private var pump: StreamPump?
|
private var pump: StreamPump?
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
/// Stage-2 presenter (opt-in via `punktfunk.presenter`): a CAMetalLayer sublayer driven by a
|
/// Stage-2 presenter (default): a CAMetalLayer sublayer driven by a CADisplayLink instead of the
|
||||||
/// CADisplayLink instead of the StreamPump → displayLayer path. nil = stage-1 (default).
|
/// StreamPump → displayLayer path. nil = stage-1 (Metal-unavailable fallback / DEBUG toggle).
|
||||||
var presentMeter: LatencyMeter?
|
var presentMeter: LatencyMeter?
|
||||||
private var stage2: Stage2Pipeline?
|
private var stage2: Stage2Pipeline?
|
||||||
private var stage2Link: CADisplayLink?
|
private var stage2Link: CADisplayLink?
|
||||||
@@ -136,6 +141,13 @@ public final class StreamViewController: UIViewController {
|
|||||||
|
|
||||||
public override func loadView() {
|
public override func loadView() {
|
||||||
view = StreamLayerUIView()
|
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)
|
#if os(iOS)
|
||||||
// Hide the iPadOS cursor while it hovers the video: the host renders its own
|
// 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
|
// 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)
|
#if os(iOS)
|
||||||
// Pointer lock is only meaningful on iPad (iPhone has no hardware-pointer lock) and
|
/// Whether the user wants the mouse/trackpad pointer CAPTURED (pointer lock → relative
|
||||||
// only when capture is engaged. The system additionally requires full-screen + frontmost
|
/// movement, the gaming default) rather than forwarded as an absolute position (desktop
|
||||||
// and may drop it (Slide Over/Stage Manager/backgrounding) — verified in setCaptured().
|
/// use). Read live from UserDefaults so it tracks the Settings toggle; defaults to on when
|
||||||
public override var prefersPointerLocked: Bool {
|
/// unset. iPad-only — gated again in `prefersPointerLocked`.
|
||||||
captured && UIDevice.current.userInterfaceIdiom == .pad
|
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 }
|
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||||
|
|
||||||
// If SwiftUI's UIHostingController reparents us, a plain container parent that forwards
|
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
|
||||||
// its pointer-lock decision to its children will then reach this VC. (UIHostingController
|
// returns nil, which tells the system to use THIS controller's own `prefersPointerLocked` —
|
||||||
// itself does not consult children, which is why GCMouse deltas can never arrive there —
|
// exactly what we want, since `PointerLockChain` forces our SwiftUI ancestors to forward the
|
||||||
// the touch path, always forwarded, is the unconditional fallback.)
|
// downward walk to us and we are the terminal anchor. Returning `self` here would make the
|
||||||
public override var childViewControllerForPointerLock: UIViewController? { self }
|
// 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
|
#endif
|
||||||
|
|
||||||
func start(
|
func start(
|
||||||
@@ -190,18 +241,22 @@ public final class StreamViewController: UIViewController {
|
|||||||
guard self?.captureEnabled == true else { return }
|
guard self?.captureEnabled == true else { return }
|
||||||
connection?.send(event)
|
connection?.send(event)
|
||||||
}
|
}
|
||||||
// Indirect pointer (mouse/trackpad with no lock) → absolute cursor + buttons, routed
|
// Indirect pointer (mouse/trackpad) WITHOUT a lock → absolute cursor + buttons + scroll.
|
||||||
// through InputCapture so the forwarding gate and release-on-blur apply uniformly.
|
// 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
|
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)
|
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||||
}
|
}
|
||||||
streamView.onPointerButton = { [weak self] button, down in
|
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
|
streamView.onScroll = { [weak self] dx, dy in
|
||||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||||
self.inputCapture?.sendScroll(dx: dx, dy: dy)
|
self.inputCapture?.sendScroll(dx: dx, dy: dy)
|
||||||
@@ -219,10 +274,17 @@ public final class StreamViewController: UIViewController {
|
|||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
// Presenter choice — stage-2 is the DEFAULT (VTDecompressionSession decode + a
|
||||||
// (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a
|
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder, where
|
||||||
// CAMetalLayer/display-link present; falls back here if Metal can't be set up.
|
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no
|
||||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
// 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 meter = presentMeter,
|
||||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -300,8 +362,8 @@ public final class StreamViewController: UIViewController {
|
|||||||
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
|
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
|
||||||
) {
|
) {
|
||||||
let metal = pipeline.layer
|
let metal = pipeline.layer
|
||||||
metal.contentsScale = streamView.contentScaleFactor
|
|
||||||
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
|
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
|
||||||
|
// (contentsScale + frame are set by layoutMetalLayer() just below.)
|
||||||
streamView.layer.addSublayer(metal)
|
streamView.layer.addSublayer(metal)
|
||||||
metalLayer = metal
|
metalLayer = metal
|
||||||
stage2 = pipeline
|
stage2 = pipeline
|
||||||
@@ -325,9 +387,20 @@ public final class StreamViewController: UIViewController {
|
|||||||
layoutMetalLayer()
|
layoutMetalLayer()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
|
||||||
/// so this is usually the full bounds). drawableSize is the layer's pixel size; the shader's
|
/// canonical render scale and is reliable once the controller is in the hierarchy;
|
||||||
/// fullscreen triangle scales the decoded texture to fill it.
|
/// `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() {
|
private func layoutMetalLayer() {
|
||||||
guard let metalLayer, let connection else { return }
|
guard let metalLayer, let connection else { return }
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
@@ -337,19 +410,17 @@ public final class StreamViewController: UIViewController {
|
|||||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||||
insideRect: bounds)
|
insideRect: bounds)
|
||||||
: bounds
|
: bounds
|
||||||
let scale = streamView.contentScaleFactor
|
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setDisableActions(true) // don't animate the resize
|
CATransaction.setDisableActions(true) // don't animate the resize
|
||||||
metalLayer.contentsScale = scale
|
metalLayer.contentsScale = renderScale
|
||||||
metalLayer.frame = fit
|
metalLayer.frame = fit
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func teardownStage2() {
|
private func teardownStage2() {
|
||||||
stage2Link?.invalidate()
|
stage2Link?.invalidate()
|
||||||
stage2Link = nil
|
stage2Link = nil
|
||||||
stage2?.stop()
|
stage2?.stop() // stops the pump (synchronous join) + drops the decode session
|
||||||
stage2 = nil
|
stage2 = nil
|
||||||
metalLayer?.removeFromSuperlayer()
|
metalLayer?.removeFromSuperlayer()
|
||||||
metalLayer = nil
|
metalLayer = nil
|
||||||
@@ -369,6 +440,7 @@ public final class StreamViewController: UIViewController {
|
|||||||
captured = false
|
captured = false
|
||||||
}
|
}
|
||||||
setNeedsUpdateOfPrefersPointerLocked()
|
setNeedsUpdateOfPrefersPointerLocked()
|
||||||
|
updatePointerLockChain() // (re)anchor the SwiftUI ancestors so the lock actually resolves
|
||||||
syncPointerLock() // resolve cursor + GCMouse/absolute routing for the current state
|
syncPointerLock() // resolve cursor + GCMouse/absolute routing for the current state
|
||||||
let onCaptureChange = onCaptureChange
|
let onCaptureChange = onCaptureChange
|
||||||
let captured = captured
|
let captured = captured
|
||||||
|
|||||||
@@ -49,11 +49,10 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
/// pump can re-gate on the next IDR.
|
/// pump can re-gate on the next IDR.
|
||||||
private let onDecodeError: @Sendable (OSStatus) -> Void
|
private let onDecodeError: @Sendable (OSStatus) -> Void
|
||||||
|
|
||||||
/// Latest source HDR mastering metadata (from `PunktfunkConnection.nextHdrMeta`), attached to
|
/// Whether the negotiated stream is full-chroma 4:4:4 (`connection.isChroma444`), set once at
|
||||||
/// each decoded HDR pixel buffer so the compositor tone-maps from the real grade. Guarded by its
|
/// session start before any decode. Selects the 4:4:4 decode pixel format (orthogonal to bit
|
||||||
/// own lock — written by the pump thread, read on the VT decode callback.
|
/// depth / HDR). Read inside `createSessionLocked` under `lock`.
|
||||||
private let metaLock = NSLock()
|
private var chroma444 = false
|
||||||
private var hdrMeta: PunktfunkConnection.HdrMeta?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
|
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
|
||||||
@@ -65,12 +64,13 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
|
|
||||||
deinit { teardown() }
|
deinit { teardown() }
|
||||||
|
|
||||||
/// Set the source HDR mastering metadata (drained from `PunktfunkConnection.nextHdrMeta`). It's
|
/// Select the chroma subsampling of the decode output (4:2:0 vs full-chroma 4:4:4). Call once at
|
||||||
/// attached to subsequent decoded HDR pixel buffers. Thread-safe; cheap to call on each update.
|
/// session start, before decoding, from `connection.isChroma444`. Takes effect on the next
|
||||||
public func setHdrMeta(_ meta: PunktfunkConnection.HdrMeta) {
|
/// session (re)build. Thread-safe.
|
||||||
metaLock.lock()
|
public func setChroma444(_ on: Bool) {
|
||||||
hdrMeta = meta
|
lock.lock()
|
||||||
metaLock.unlock()
|
chroma444 = on
|
||||||
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
/// 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
|
/// 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
|
/// 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
|
/// HEVC VUI, so this picks the decode bit depth (10-bit P010/x444 vs 8-bit NV12/444v) from the
|
||||||
/// (the host re-emits parameter sets with the new VUI → a new format desc → session rebuild).
|
/// 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 {
|
static func isHDRFormat(_ format: CMVideoFormatDescription) -> Bool {
|
||||||
guard
|
guard
|
||||||
let tf = CMFormatDescriptionGetExtension(
|
let tf = CMFormatDescriptionGetExtension(
|
||||||
@@ -157,11 +159,18 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
session = nil
|
session = nil
|
||||||
format = 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 hdr = Self.isHDRFormat(newFormat)
|
||||||
let pixelFormat =
|
let pixelFormat: OSType = {
|
||||||
hdr
|
switch (chroma444, hdr) {
|
||||||
? kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange // P010 (10-bit)
|
case (false, false): return kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange // NV12
|
||||||
: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange // NV12 (8-bit)
|
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] = [
|
let imageAttrs: [CFString: Any] = [
|
||||||
kCVPixelBufferMetalCompatibilityKey: true,
|
kCVPixelBufferMetalCompatibilityKey: true,
|
||||||
kCVPixelBufferPixelFormatTypeKey: pixelFormat,
|
kCVPixelBufferPixelFormatTypeKey: pixelFormat,
|
||||||
@@ -169,11 +178,20 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
var callback = VTDecompressionOutputCallbackRecord(
|
var callback = VTDecompressionOutputCallbackRecord(
|
||||||
decompressionOutputCallback: decoderOutputCallback,
|
decompressionOutputCallback: decoderOutputCallback,
|
||||||
decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque())
|
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?
|
var newSession: VTDecompressionSession?
|
||||||
let status = VTDecompressionSessionCreate(
|
let status = VTDecompressionSessionCreate(
|
||||||
allocator: kCFAllocatorDefault,
|
allocator: kCFAllocatorDefault,
|
||||||
formatDescription: newFormat,
|
formatDescription: newFormat,
|
||||||
decoderSpecification: nil, // hardware by default
|
decoderSpecification: spec,
|
||||||
imageBufferAttributes: imageAttrs as CFDictionary,
|
imageBufferAttributes: imageAttrs as CFDictionary,
|
||||||
outputCallback: &callback,
|
outputCallback: &callback,
|
||||||
decompressionSessionOut: &newSession)
|
decompressionSessionOut: &newSession)
|
||||||
@@ -195,26 +213,17 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
// pts was stamped at timescale 1e9 (AnnexB.sampleBuffer); normalize defensively.
|
// pts was stamped at timescale 1e9 (AnnexB.sampleBuffer); normalize defensively.
|
||||||
let p = CMTimeConvertScale(pts, timescale: 1_000_000_000, method: .default)
|
let p = CMTimeConvertScale(pts, timescale: 1_000_000_000, method: .default)
|
||||||
let ptsNs = p.value > 0 ? UInt64(p.value) : 0
|
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 =
|
let isHDR =
|
||||||
CVPixelBufferGetPixelFormatType(imageBuffer)
|
fmt == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
||||||
== kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|
|| fmt == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
||||||
// Attach the source's mastering display + content light level (ST.2086 / CEA-861.3) so the
|
|| fmt == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
||||||
// compositor tone-maps from the real grade rather than inferring from the PQ colourspace
|
|| fmt == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onDecoded(
|
onDecoded(
|
||||||
ReadyFrame(ptsNs: ptsNs, decodedNs: decodedNs, pixelBuffer: imageBuffer, isHDR: isHDR))
|
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"],
|
[2560, 1440, "2560 × 1440"],
|
||||||
];
|
];
|
||||||
const REFRESH = [0, 30, 60, 90, 120];
|
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 SettingsSection: FC = () => {
|
||||||
const [s, setS] = useState<StreamSettings | null>(null);
|
const [s, setS] = useState<StreamSettings | null>(null);
|
||||||
@@ -355,14 +361,17 @@ const SettingsSection: FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Field label="Gamepad type" childrenContainerWidth="max">
|
<Field label="Gamepad type" childrenContainerWidth="max">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
rgOptions={GAMEPADS.map((g) => ({
|
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||||
data: g,
|
|
||||||
label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense",
|
|
||||||
}))}
|
|
||||||
selectedOption={s.gamepad}
|
selectedOption={s.gamepad}
|
||||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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
|
<ToggleField
|
||||||
label="Stream microphone"
|
label="Stream microphone"
|
||||||
checked={s.mic_enabled}
|
checked={s.mic_enabled}
|
||||||
|
|||||||
@@ -113,12 +113,35 @@ async function ensureShortcut(): Promise<number> {
|
|||||||
return appId;
|
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
|
* 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.
|
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
|
||||||
*/
|
*/
|
||||||
export async function launchStream(host: string, port: number): Promise<void> {
|
export async function launchStream(host: string, port: number): Promise<void> {
|
||||||
const appId = await ensureShortcut();
|
const appId = 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;
|
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||||
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
||||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
||||||
|
|||||||
+158
-6
@@ -295,19 +295,21 @@ fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
||||||
tofu_dialog(app, req);
|
tofu_dialog(app, req);
|
||||||
} else {
|
} else {
|
||||||
// Rule 3b: pair=required or unknown policy — PIN pairing is mandatory.
|
// Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval
|
||||||
pin_dialog(app, req);
|
// (request access → approve in the console) or the PIN ceremony.
|
||||||
|
approval_dialog(app, req);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Manual entry (no advertised fingerprint). A known address connects silently
|
// Manual entry (no advertised fingerprint). A known address connects silently
|
||||||
// on its stored pin (rule 1); an unknown one must pair — never silent TOFU.
|
// on its stored pin (rule 1); an unknown one must pair — request access (approve in
|
||||||
|
// the console) or use a PIN; never silent TOFU.
|
||||||
match known
|
match known
|
||||||
.find_by_addr(&req.addr, req.port)
|
.find_by_addr(&req.addr, req.port)
|
||||||
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
|
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
|
||||||
{
|
{
|
||||||
Some(pin) => start_session(app, req, Some(pin)),
|
Some(pin) => start_session(app, req, Some(pin)),
|
||||||
None => pin_dialog(app, req), // rule 3b
|
None => approval_dialog(app, req), // rule 3b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,6 +420,83 @@ fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
|||||||
dialog.present(Some(&parent));
|
dialog.present(Some(&parent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN
|
||||||
|
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
||||||
|
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
||||||
|
fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
let dialog = adw::AlertDialog::new(
|
||||||
|
Some("Pairing Required"),
|
||||||
|
Some(&format!(
|
||||||
|
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
||||||
|
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
||||||
|
req.name
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
dialog.add_responses(&[
|
||||||
|
("cancel", "Cancel"),
|
||||||
|
("pin", "Use a PIN instead…"),
|
||||||
|
("request", "Request Access"),
|
||||||
|
]);
|
||||||
|
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("request"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let parent = app.window.clone();
|
||||||
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
|
"request" => request_access(app.clone(), req.clone()),
|
||||||
|
"pin" => pin_dialog(app.clone(), req.clone()),
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
dialog.present(Some(&parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||||
|
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
||||||
|
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
||||||
|
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||||
|
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
|
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
||||||
|
let cancel = Rc::new(std::cell::Cell::new(false));
|
||||||
|
|
||||||
|
let waiting = adw::AlertDialog::new(
|
||||||
|
Some("Waiting for Approval"),
|
||||||
|
Some(&format!(
|
||||||
|
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
||||||
|
connects automatically once you approve it.",
|
||||||
|
glib::host_name(),
|
||||||
|
req.name
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||||
|
waiting.set_close_response("cancel");
|
||||||
|
{
|
||||||
|
let app = app.clone();
|
||||||
|
let cancel = cancel.clone();
|
||||||
|
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||||
|
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
||||||
|
// down silently by the event loop (see StartOpts::cancel).
|
||||||
|
cancel.set(true);
|
||||||
|
app.busy.set(false);
|
||||||
|
app.toast("Cancelled — the request may still be pending on the host.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
waiting.present(Some(&app.window));
|
||||||
|
|
||||||
|
start_session_with(
|
||||||
|
app,
|
||||||
|
req,
|
||||||
|
pin,
|
||||||
|
StartOpts {
|
||||||
|
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||||
|
// approval still lands on this connection rather than timing the client out first.
|
||||||
|
connect_timeout: std::time::Duration::from_secs(185),
|
||||||
|
persist_paired: true,
|
||||||
|
waiting: Some(waiting),
|
||||||
|
cancel: Some(cancel),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
||||||
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
||||||
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
||||||
@@ -556,7 +635,42 @@ fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
|
|||||||
mode
|
mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tunables for a session start that differ between the normal connect and the "request access"
|
||||||
|
/// (delegated-approval) flow. `Default` is the normal connect.
|
||||||
|
struct StartOpts {
|
||||||
|
/// Handshake budget. The request-access flow uses a long one because the host PARKS the
|
||||||
|
/// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`).
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||||
|
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||||
|
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||||
|
persist_paired: bool,
|
||||||
|
/// A "waiting for approval" dialog to dismiss on the first session event (request-access only).
|
||||||
|
waiting: Option<adw::AlertDialog>,
|
||||||
|
/// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with
|
||||||
|
/// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves
|
||||||
|
/// the in-flight connect to time out; when it finally resolves, the event loop sees this flag
|
||||||
|
/// and tears down silently (drops the connector → closes the connection) without touching the
|
||||||
|
/// UI a new session may already own.
|
||||||
|
cancel: Option<Rc<std::cell::Cell<bool>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StartOpts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
connect_timeout: std::time::Duration::from_secs(15),
|
||||||
|
persist_paired: false,
|
||||||
|
waiting: None,
|
||||||
|
cancel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||||
|
start_session_with(app, req, pin, StartOpts::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>, opts: StartOpts) {
|
||||||
if app.busy.replace(true) {
|
if app.busy.replace(true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -577,10 +691,14 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
audio_channels: s.audio_channels,
|
audio_channels: s.audio_channels,
|
||||||
pin,
|
pin,
|
||||||
identity: app.identity.clone(),
|
identity: app.identity.clone(),
|
||||||
|
connect_timeout: opts.connect_timeout,
|
||||||
};
|
};
|
||||||
let inhibit = s.inhibit_shortcuts;
|
let inhibit = s.inhibit_shortcuts;
|
||||||
drop(s);
|
drop(s);
|
||||||
let tofu = pin.is_none();
|
let tofu = pin.is_none();
|
||||||
|
let persist_paired = opts.persist_paired;
|
||||||
|
let mut waiting = opts.waiting;
|
||||||
|
let cancel = opts.cancel;
|
||||||
|
|
||||||
let mut handle = crate::session::start(params);
|
let mut handle = crate::session::start(params);
|
||||||
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
||||||
@@ -588,14 +706,41 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
let mut frames = Some(frames);
|
let mut frames = Some(frames);
|
||||||
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
||||||
while let Ok(event) = handle.events.recv().await {
|
while let Ok(event) = handle.events.recv().await {
|
||||||
|
// A cancelled request-access connect resolved late: tear down silently. Don't touch
|
||||||
|
// app.busy — Cancel already cleared it, and a fresh session may now own it.
|
||||||
|
if cancel.as_ref().is_some_and(|c| c.get()) {
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
match event {
|
match event {
|
||||||
SessionEvent::Connected {
|
SessionEvent::Connected {
|
||||||
connector,
|
connector,
|
||||||
mode,
|
mode,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
} => {
|
} => {
|
||||||
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
// Dismiss the "waiting for approval" dialog (request-access flow), if any.
|
||||||
if tofu {
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
|
if persist_paired {
|
||||||
|
// Request-access: the operator approved this device, so record the host as
|
||||||
|
// a trusted PAIRED host (pinning the fingerprint we observed) — future
|
||||||
|
// connects are then silent (rule 1), exactly like after a PIN ceremony.
|
||||||
|
let fp_hex = crate::trust::hex(&fingerprint);
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
known.upsert(KnownHost {
|
||||||
|
name: req.name.clone(),
|
||||||
|
addr: req.addr.clone(),
|
||||||
|
port: req.port,
|
||||||
|
fp_hex,
|
||||||
|
paired: true,
|
||||||
|
});
|
||||||
|
let _ = known.save();
|
||||||
|
app.toast("Approved — connecting…");
|
||||||
|
} else if tofu {
|
||||||
|
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||||||
let fp_hex = crate::trust::hex(&fingerprint);
|
let fp_hex = crate::trust::hex(&fingerprint);
|
||||||
let mut known = KnownHosts::load();
|
let mut known = KnownHosts::load();
|
||||||
known.upsert(KnownHost {
|
known.upsert(KnownHost {
|
||||||
@@ -622,6 +767,7 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
connector,
|
connector,
|
||||||
frames.take().expect("Connected delivered once"),
|
frames.take().expect("Connected delivered once"),
|
||||||
app.gamepad.escape_events(),
|
app.gamepad.escape_events(),
|
||||||
|
app.gamepad.disconnect_events(),
|
||||||
handle.stop.clone(),
|
handle.stop.clone(),
|
||||||
inhibit,
|
inhibit,
|
||||||
&title,
|
&title,
|
||||||
@@ -644,6 +790,9 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
msg,
|
msg,
|
||||||
trust_rejected,
|
trust_rejected,
|
||||||
} => {
|
} => {
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
tracing::warn!(%msg, trust_rejected, "connect failed");
|
tracing::warn!(%msg, trust_rejected, "connect failed");
|
||||||
app.busy.set(false);
|
app.busy.set(false);
|
||||||
// A pinned connect rejected on trust grounds means the host's cert no
|
// A pinned connect rejected on trust grounds means the host's cert no
|
||||||
@@ -658,6 +807,9 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
SessionEvent::Ended(err) => {
|
SessionEvent::Ended(err) => {
|
||||||
|
if let Some(w) = waiting.take() {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
app.gamepad.detach();
|
app.gamepad.detach();
|
||||||
app.nav.pop_to_tag("hosts");
|
app.nav.pop_to_tag("hosts");
|
||||||
if let Some(e) = err {
|
if let Some(e) = err {
|
||||||
|
|||||||
+186
-32
@@ -18,7 +18,7 @@ use punktfunk_core::quic::{HidOutput, RichInput};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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];
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PadInfo {
|
pub struct PadInfo {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -58,6 +65,7 @@ impl PadInfo {
|
|||||||
GamepadPref::DualSense => "DualSense",
|
GamepadPref::DualSense => "DualSense",
|
||||||
GamepadPref::DualShock4 => "DualShock 4",
|
GamepadPref::DualShock4 => "DualShock 4",
|
||||||
GamepadPref::XboxOne => "Xbox One",
|
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
|
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
||||||
/// fullscreen + release capture.
|
/// fullscreen + release capture.
|
||||||
escape_rx: async_channel::Receiver<()>,
|
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 {
|
impl GamepadService {
|
||||||
@@ -98,11 +109,12 @@ impl GamepadService {
|
|||||||
let pinned = Arc::new(Mutex::new(None));
|
let pinned = Arc::new(Mutex::new(None));
|
||||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||||
|
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
|
||||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||||
if let Err(e) = std::thread::Builder::new()
|
if let Err(e) = std::thread::Builder::new()
|
||||||
.name("punktfunk-gamepad".into())
|
.name("punktfunk-gamepad".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
|
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) {
|
||||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -115,6 +127,7 @@ impl GamepadService {
|
|||||||
pinned,
|
pinned,
|
||||||
ctl,
|
ctl,
|
||||||
escape_rx,
|
escape_rx,
|
||||||
|
disconnect_rx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +137,12 @@ impl GamepadService {
|
|||||||
self.escape_rx.clone()
|
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> {
|
pub fn pads(&self) -> Vec<PadInfo> {
|
||||||
self.pads.lock().unwrap().clone()
|
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::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
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,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -259,11 +285,22 @@ struct Worker {
|
|||||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||||
last_axis: [i32; 6],
|
last_axis: [i32; 6],
|
||||||
held_buttons: Vec<u32>,
|
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],
|
last_accel: [i16; 3],
|
||||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||||
escape_tx: async_channel::Sender<()>,
|
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.
|
/// The escape chord is fully held — latched so it fires once, not every poll.
|
||||||
chord_armed: bool,
|
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 {
|
impl Worker {
|
||||||
@@ -275,13 +312,22 @@ impl Worker {
|
|||||||
|
|
||||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||||
let pad = self.opened.get(&id)?;
|
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 {
|
Some(PadInfo {
|
||||||
id,
|
id,
|
||||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||||
pref: pref_for_type(
|
pref,
|
||||||
self.subsystem
|
|
||||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,32 +343,90 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
*v = i32::MIN;
|
*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 {
|
} else {
|
||||||
self.held_buttons.clear();
|
self.held_buttons.clear();
|
||||||
self.last_axis = [i32::MIN; 6];
|
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
|
/// 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) {
|
fn maybe_fire_escape(&mut self) {
|
||||||
if self.chord_armed {
|
if self.chord_armed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||||
self.chord_armed = true;
|
self.chord_armed = true;
|
||||||
|
self.chord_since = Some(Instant::now());
|
||||||
let _ = self.escape_tx.try_send(());
|
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).
|
/// Re-arm once the chord is broken (any of its buttons released).
|
||||||
fn rearm_escape(&mut self) {
|
fn rearm_escape(&mut self) {
|
||||||
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
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).
|
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||||
fn set_sensors(&mut self, enabled: bool) {
|
fn set_sensors(&mut self, enabled: bool) {
|
||||||
let Some(id) = self.active_id() else { return };
|
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)]
|
#[allow(clippy::too_many_lines)]
|
||||||
@@ -344,11 +498,18 @@ fn run(
|
|||||||
pinned_out: &Mutex<Option<u32>>,
|
pinned_out: &Mutex<Option<u32>>,
|
||||||
ctl: &Receiver<Ctl>,
|
ctl: &Receiver<Ctl>,
|
||||||
escape_tx: &async_channel::Sender<()>,
|
escape_tx: &async_channel::Sender<()>,
|
||||||
|
disconnect_tx: &async_channel::Sender<()>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||||
// own thread.
|
// own thread.
|
||||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||||
|
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||||
|
// 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 sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||||
@@ -361,9 +522,13 @@ fn run(
|
|||||||
attached: None,
|
attached: None,
|
||||||
last_axis: [i32::MIN; 6],
|
last_axis: [i32::MIN; 6],
|
||||||
held_buttons: Vec::new(),
|
held_buttons: Vec::new(),
|
||||||
|
held_touches: std::collections::HashSet::new(),
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
escape_tx: escape_tx.clone(),
|
escape_tx: escape_tx.clone(),
|
||||||
|
disconnect_tx: disconnect_tx.clone(),
|
||||||
chord_armed: false,
|
chord_armed: false,
|
||||||
|
chord_since: None,
|
||||||
|
disconnect_fired: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let publish = |w: &Worker| {
|
let publish = |w: &Worker| {
|
||||||
@@ -381,6 +546,7 @@ fn run(
|
|||||||
Ok(Ctl::Attach(c)) => {
|
Ok(Ctl::Attach(c)) => {
|
||||||
w.attached = Some(c);
|
w.attached = Some(c);
|
||||||
w.last_axis = [i32::MIN; 6];
|
w.last_axis = [i32::MIN; 6];
|
||||||
|
w.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||||
w.set_sensors(true);
|
w.set_sensors(true);
|
||||||
}
|
}
|
||||||
Ok(Ctl::Detach) => {
|
Ok(Ctl::Detach) => {
|
||||||
@@ -474,9 +640,11 @@ fn run(
|
|||||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
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 {
|
Event::ControllerTouchpadDown {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -484,41 +652,23 @@ fn run(
|
|||||||
}
|
}
|
||||||
| Event::ControllerTouchpadMotion {
|
| Event::ControllerTouchpadMotion {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
..
|
..
|
||||||
} if active == Some(which) && w.attached.is_some() => {
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
let _ = w
|
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||||
.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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Event::ControllerTouchpadUp {
|
Event::ControllerTouchpadUp {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
..
|
..
|
||||||
} if active == Some(which) && w.attached.is_some() => {
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
let _ = w
|
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||||
.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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Motion: accel events update the cache; each gyro event ships a sample
|
// Motion: accel events update the cache; each gyro event ships a sample
|
||||||
// (the DualSense reports both at ~250 Hz). Scale convention shared with
|
// (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
|
// Feedback planes (this thread is their single consumer). The host re-sends
|
||||||
// rumble state periodically, so a generous duration with refresh-on-update is
|
// rumble state periodically, so a generous duration with refresh-on-update is
|
||||||
// safe — a dropped stop heals within ~500 ms.
|
// safe — a dropped stop heals within ~500 ms.
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ pub struct SessionParams {
|
|||||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
|
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||||
|
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||||
|
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||||
|
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||||
|
pub connect_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
#[derive(Clone, Copy, Default)]
|
||||||
@@ -139,7 +144,7 @@ fn pump(
|
|||||||
None, // launch: the Linux client has no library picker yet
|
None, // launch: the Linux client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
Duration::from_secs(15),
|
params.connect_timeout,
|
||||||
) {
|
) {
|
||||||
Ok(c) => Arc::new(c),
|
Ok(c) => Arc::new(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -19,6 +19,49 @@ const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
|||||||
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
|
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
|
||||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||||
|
|
||||||
|
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||||
|
const APP_LICENSE: &str = concat!(
|
||||||
|
"punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
|
||||||
|
"================================ MIT ================================\n\n",
|
||||||
|
include_str!("../../../LICENSE-MIT"),
|
||||||
|
"\n\n=============================== Apache-2.0 ===============================\n\n",
|
||||||
|
include_str!("../../../LICENSE-APACHE"),
|
||||||
|
);
|
||||||
|
/// Third-party software notices for the linked Rust crates (generated by
|
||||||
|
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
|
||||||
|
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||||
|
|
||||||
|
/// Show the About dialog (app license + the third-party-software Legal section).
|
||||||
|
fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||||
|
let about = adw::AboutDialog::builder()
|
||||||
|
.application_name("punktfunk")
|
||||||
|
.developer_name("unom")
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
|
.website("https://git.unom.io/unom/punktfunk")
|
||||||
|
.license_type(gtk::License::Custom)
|
||||||
|
.license(APP_LICENSE)
|
||||||
|
.build();
|
||||||
|
// The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own
|
||||||
|
// (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set.
|
||||||
|
about.add_legal_section(
|
||||||
|
"Third-party software (Rust crates)",
|
||||||
|
None,
|
||||||
|
gtk::License::Custom,
|
||||||
|
Some(THIRD_PARTY_NOTICES),
|
||||||
|
);
|
||||||
|
about.add_legal_section(
|
||||||
|
"Third-party software (system libraries)",
|
||||||
|
None,
|
||||||
|
gtk::License::Custom,
|
||||||
|
Some(
|
||||||
|
"This application dynamically links system libraries under their own licenses, \
|
||||||
|
including FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), \
|
||||||
|
and SDL 3 (Zlib). Their full license texts are available from each project.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
about.present(Some(parent));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn show(
|
pub fn show(
|
||||||
parent: &impl IsA<gtk::Widget>,
|
parent: &impl IsA<gtk::Widget>,
|
||||||
settings: Rc<RefCell<Settings>>,
|
settings: Rc<RefCell<Settings>>,
|
||||||
@@ -156,9 +199,23 @@ pub fn show(
|
|||||||
.build();
|
.build();
|
||||||
audio.add(&mic_row);
|
audio.add(&mic_row);
|
||||||
|
|
||||||
|
let about = adw::PreferencesGroup::builder().title("About").build();
|
||||||
|
let licenses_row = adw::ActionRow::builder()
|
||||||
|
.title("Third-party licenses")
|
||||||
|
.subtitle("Open-source software used by punktfunk")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
licenses_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
{
|
||||||
|
let about_parent: gtk::Widget = parent.clone().upcast();
|
||||||
|
licenses_row.connect_activated(move |_| show_about(&about_parent));
|
||||||
|
}
|
||||||
|
about.add(&licenses_row);
|
||||||
|
|
||||||
page.add(&stream);
|
page.add(&stream);
|
||||||
page.add(&input);
|
page.add(&input);
|
||||||
page.add(&audio);
|
page.add(&audio);
|
||||||
|
page.add(&about);
|
||||||
|
|
||||||
// Seed from the current settings.
|
// Seed from the current settings.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -124,12 +124,13 @@ impl Capture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
frames: async_channel::Receiver<DecodedFrame>,
|
frames: async_channel::Receiver<DecodedFrame>,
|
||||||
escape_rx: async_channel::Receiver<()>,
|
escape_rx: async_channel::Receiver<()>,
|
||||||
|
disconnect_rx: async_channel::Receiver<()>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -152,7 +153,7 @@ pub fn new(
|
|||||||
stats_label.set_margin_top(12);
|
stats_label.set_margin_top(12);
|
||||||
|
|
||||||
let hint = gtk::Label::new(Some(
|
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.add_css_class("osd");
|
||||||
hint.set_halign(gtk::Align::Center);
|
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
|
// 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
|
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
||||||
// only way out on a Steam Deck).
|
// 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.add_css_class("osd");
|
||||||
fs_hint.set_halign(gtk::Align::Center);
|
fs_hint.set_halign(gtk::Align::Center);
|
||||||
fs_hint.set_valign(gtk::Align::Start);
|
fs_hint.set_valign(gtk::Align::Start);
|
||||||
@@ -297,6 +300,7 @@ pub fn new(
|
|||||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||||
let cap = capture.clone();
|
let cap = capture.clone();
|
||||||
let window_k = window.clone();
|
let window_k = window.clone();
|
||||||
|
let stop_kb = stop.clone();
|
||||||
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
||||||
let chord = gdk::ModifierType::CONTROL_MASK
|
let chord = gdk::ModifierType::CONTROL_MASK
|
||||||
| gdk::ModifierType::ALT_MASK
|
| gdk::ModifierType::ALT_MASK
|
||||||
@@ -309,6 +313,13 @@ pub fn new(
|
|||||||
}
|
}
|
||||||
return glib::Propagation::Stop;
|
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 keyval == gdk::Key::F11 {
|
||||||
if window_k.is_fullscreen() {
|
if window_k.is_fullscreen() {
|
||||||
window_k.unfullscreen();
|
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
|
// 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.
|
// 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 stop_h = stop.clone();
|
||||||
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
||||||
let escape_future = RefCell::new(Some(escape_future));
|
let escape_future = RefCell::new(Some(escape_future));
|
||||||
|
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||||
page.connect_hidden(move |_| {
|
page.connect_hidden(move |_| {
|
||||||
tracing::debug!("stream page hidden — ending session");
|
tracing::debug!("stream page hidden — ending session");
|
||||||
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
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() {
|
if let Some(f) = escape_future.borrow_mut().take() {
|
||||||
f.abort();
|
f.abort();
|
||||||
}
|
}
|
||||||
|
if let Some(f) = disconnect_future.borrow_mut().take() {
|
||||||
|
f.abort();
|
||||||
|
}
|
||||||
if window.is_fullscreen() {
|
if window.is_fullscreen() {
|
||||||
window.unfullscreen();
|
window.unfullscreen();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,11 +76,29 @@ foreach ($f in $required) {
|
|||||||
Copy-Item $src (Join-Path $layout $f) -Force
|
Copy-Item $src (Join-Path $layout $f) -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct)
|
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct).
|
||||||
|
# These are unmodified BtbN *lgpl-shared* builds, linked dynamically (replaceable DLLs) — FFmpeg is
|
||||||
|
# used under the LGPL v2.1+; the license text + notice ship in licenses\ below.
|
||||||
$ff = Get-ChildItem -Path $FfmpegBin -Filter *.dll -ErrorAction SilentlyContinue
|
$ff = Get-ChildItem -Path $FfmpegBin -Filter *.dll -ErrorAction SilentlyContinue
|
||||||
if (-not $ff) { throw "no FFmpeg DLLs in $FfmpegBin" }
|
if (-not $ff) { throw "no FFmpeg DLLs in $FfmpegBin" }
|
||||||
$ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force }
|
$ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force }
|
||||||
|
|
||||||
|
# license/attribution payload (MSIX has no installer EULA page, so ship them as files): FFmpeg's LGPL
|
||||||
|
# notice + license text, the project's own MIT/Apache texts, and the generated third-party notices.
|
||||||
|
$licDir = Join-Path $layout 'licenses'
|
||||||
|
New-Item -ItemType Directory -Force -Path $licDir | Out-Null
|
||||||
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
|
||||||
|
Copy-Item (Join-Path $repoRoot 'packaging\windows\licenses\FFmpeg-LGPL-NOTICE.txt') $licDir -Force -ErrorAction SilentlyContinue
|
||||||
|
foreach ($n in @('THIRD-PARTY-NOTICES.txt', 'LICENSE-MIT', 'LICENSE-APACHE')) {
|
||||||
|
$p = Join-Path $repoRoot $n
|
||||||
|
if (Test-Path $p) { Copy-Item $p $licDir -Force }
|
||||||
|
}
|
||||||
|
$ffRoot = Split-Path $FfmpegBin -Parent
|
||||||
|
foreach ($lic in @('LICENSE.txt', 'LICENSE', 'COPYING.LGPLv2.1', 'COPYING.LGPLv3', 'COPYING.txt')) {
|
||||||
|
$p = Join-Path $ffRoot $lic
|
||||||
|
if (Test-Path $p) { Copy-Item $p $licDir -Force }
|
||||||
|
}
|
||||||
|
|
||||||
# tile/store assets
|
# tile/store assets
|
||||||
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
|
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
|
||||||
|
|
||||||
|
|||||||
+301
-15
@@ -20,7 +20,9 @@ use crate::video::{DecodedFrame, DecoderPref};
|
|||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
use windows_reactor::*;
|
use windows_reactor::*;
|
||||||
|
|
||||||
const RESOLUTIONS: &[(u32, u32)] = &[
|
const RESOLUTIONS: &[(u32, u32)] = &[
|
||||||
@@ -43,12 +45,27 @@ const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150];
|
|||||||
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
||||||
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
||||||
|
|
||||||
|
/// punktfunk's own license (MIT OR Apache-2.0), shown on the Licenses screen.
|
||||||
|
const APP_LICENSE: &str = concat!(
|
||||||
|
include_str!("../../../LICENSE-MIT"),
|
||||||
|
"\n\n================================ Apache-2.0 ================================\n\n",
|
||||||
|
include_str!("../../../LICENSE-APACHE"),
|
||||||
|
);
|
||||||
|
/// Third-party software notices for the linked Rust crates (generated by
|
||||||
|
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
|
||||||
|
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
enum Screen {
|
enum Screen {
|
||||||
Hosts,
|
Hosts,
|
||||||
Connecting,
|
Connecting,
|
||||||
|
/// The no-PIN "request access" wait: an identified connect is in flight, parked by the host
|
||||||
|
/// until the operator approves this device in its console. Cancelable.
|
||||||
|
RequestAccess,
|
||||||
Stream,
|
Stream,
|
||||||
Settings,
|
Settings,
|
||||||
|
/// Open-source / third-party license notices (reached from Settings).
|
||||||
|
Licenses,
|
||||||
Pair,
|
Pair,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +149,11 @@ struct Shared {
|
|||||||
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
||||||
/// by the stream page's HUD poll thread to drive the overlay.
|
/// by the stream page's HUD poll thread to drive the overlay.
|
||||||
stats: Mutex<Stats>,
|
stats: Mutex<Stats>,
|
||||||
|
/// Cancel flag for the in-flight "request access" connect. A FRESH flag is installed per
|
||||||
|
/// request: the waiting screen's Cancel button reads it back from here and sets it, and that
|
||||||
|
/// request's event loop (which captured the same `Arc` at spawn) then tears down silently when
|
||||||
|
/// the parked connect finally resolves. `None` outside a request-access flow.
|
||||||
|
cancel: Mutex<Option<Arc<AtomicBool>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppCtx {
|
pub struct AppCtx {
|
||||||
@@ -376,8 +398,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
.vertical_alignment(VerticalAlignment::Center)
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// request_access_page (like settings_page/Connecting) uses no hooks, so calling it inline
|
||||||
|
// is sound — it only wires a Cancel button to the shared cancel flag + navigation.
|
||||||
|
Screen::RequestAccess => request_access_page(ctx, &set_screen),
|
||||||
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
|
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
|
||||||
Screen::Settings => settings_page(ctx, &set_screen),
|
Screen::Settings => settings_page(ctx, &set_screen),
|
||||||
|
// licenses_page is a static text screen (no hooks), so inline is sound.
|
||||||
|
Screen::Licenses => licenses_page(&set_screen),
|
||||||
Screen::Pair => component(pair_page, svc),
|
Screen::Pair => component(pair_page, svc),
|
||||||
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
|
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
|
||||||
}
|
}
|
||||||
@@ -569,12 +596,61 @@ fn initiate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tunables that differ between the normal connect and the no-PIN "request access" flow.
|
||||||
|
/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the
|
||||||
|
/// plain "Connecting" screen.
|
||||||
|
struct ConnectOpts {
|
||||||
|
/// Handshake budget. Request-access uses a long one because the host PARKS the connection
|
||||||
|
/// until the operator clicks Approve in its console (see the host's `PENDING_APPROVAL_WAIT`).
|
||||||
|
connect_timeout: Duration,
|
||||||
|
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||||
|
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||||
|
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||||
|
persist_paired: bool,
|
||||||
|
/// Show the cancelable "waiting for approval" screen instead of "Connecting" (request-access).
|
||||||
|
awaiting_approval: bool,
|
||||||
|
/// Set by the waiting screen's Cancel button. `NativeClient::connect` is blocking with no
|
||||||
|
/// abort, so Cancel returns the UI immediately and leaves the parked connect to resolve/time
|
||||||
|
/// out; this request's event loop then sees the flag and tears down silently (drops the
|
||||||
|
/// connector → closes the connection) without touching a screen a new session may already own.
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConnectOpts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
connect_timeout: Duration::from_secs(15),
|
||||||
|
persist_paired: false,
|
||||||
|
awaiting_approval: false,
|
||||||
|
cancel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn connect(
|
fn connect(
|
||||||
ctx: &Arc<AppCtx>,
|
ctx: &Arc<AppCtx>,
|
||||||
target: &Target,
|
target: &Target,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
set_screen: &AsyncSetState<Screen>,
|
set_screen: &AsyncSetState<Screen>,
|
||||||
set_status: &AsyncSetState<String>,
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
connect_with(
|
||||||
|
ctx,
|
||||||
|
target,
|
||||||
|
pin,
|
||||||
|
set_screen,
|
||||||
|
set_status,
|
||||||
|
ConnectOpts::default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_with(
|
||||||
|
ctx: &Arc<AppCtx>,
|
||||||
|
target: &Target,
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
opts: ConnectOpts,
|
||||||
) {
|
) {
|
||||||
let s = ctx.settings.lock().unwrap().clone();
|
let s = ctx.settings.lock().unwrap().clone();
|
||||||
let mode = if s.width != 0 && s.refresh_hz != 0 {
|
let mode = if s.width != 0 && s.refresh_hz != 0 {
|
||||||
@@ -607,29 +683,54 @@ fn connect(
|
|||||||
decoder: DecoderPref::from_name(&s.decoder),
|
decoder: DecoderPref::from_name(&s.decoder),
|
||||||
pin,
|
pin,
|
||||||
identity: ctx.identity.clone(),
|
identity: ctx.identity.clone(),
|
||||||
|
connect_timeout: opts.connect_timeout,
|
||||||
});
|
});
|
||||||
set_status.call(String::new());
|
set_status.call(String::new());
|
||||||
set_screen.call(Screen::Connecting);
|
set_screen.call(if opts.awaiting_approval {
|
||||||
|
Screen::RequestAccess
|
||||||
|
} else {
|
||||||
|
Screen::Connecting
|
||||||
|
});
|
||||||
|
|
||||||
let tofu = pin.is_none();
|
let tofu = pin.is_none();
|
||||||
|
let persist_paired = opts.persist_paired;
|
||||||
|
let cancel = opts.cancel;
|
||||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||||
let target = target.clone();
|
let target = target.clone();
|
||||||
std::thread::spawn(move || loop {
|
std::thread::spawn(move || loop {
|
||||||
match handle.events.recv_blocking() {
|
let event = match handle.events.recv_blocking() {
|
||||||
Ok(SessionEvent::Connected {
|
Ok(e) => e,
|
||||||
|
Err(_) => {
|
||||||
|
gamepad.detach();
|
||||||
|
ss.call(Screen::Hosts);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// A cancelled request-access connect that resolved late (the host approved or the park
|
||||||
|
// timed out after the user walked away): tear down silently. Cancel already returned the
|
||||||
|
// UI to the host list; dropping `event` (and with it any connector) closes the connection
|
||||||
|
// without popping a stream or a stray error over the screen a new session may own.
|
||||||
|
if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match event {
|
||||||
|
SessionEvent::Connected {
|
||||||
connector,
|
connector,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
..
|
..
|
||||||
}) => {
|
} => {
|
||||||
if tofu {
|
if persist_paired || tofu {
|
||||||
|
// Request-access: the operator approved this device, so record the host as a
|
||||||
|
// trusted PAIRED host — future connects are then silent (rule 1), exactly like
|
||||||
|
// after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned).
|
||||||
let mut k = KnownHosts::load();
|
let mut k = KnownHosts::load();
|
||||||
k.upsert(KnownHost {
|
k.upsert(KnownHost {
|
||||||
name: target.name.clone(),
|
name: target.name.clone(),
|
||||||
addr: target.addr.clone(),
|
addr: target.addr.clone(),
|
||||||
port: target.port,
|
port: target.port,
|
||||||
fp_hex: trust::hex(&fingerprint),
|
fp_hex: trust::hex(&fingerprint),
|
||||||
paired: false,
|
paired: persist_paired,
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
@@ -638,10 +739,10 @@ fn connect(
|
|||||||
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
||||||
ss.call(Screen::Stream);
|
ss.call(Screen::Stream);
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Failed {
|
SessionEvent::Failed {
|
||||||
msg,
|
msg,
|
||||||
trust_rejected,
|
trust_rejected,
|
||||||
}) => {
|
} => {
|
||||||
st.call(msg);
|
st.call(msg);
|
||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
if trust_rejected {
|
if trust_rejected {
|
||||||
@@ -653,22 +754,100 @@ fn connect(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Ended(err)) => {
|
SessionEvent::Ended(err) => {
|
||||||
st.call(err.unwrap_or_else(|| "Session ended".into()));
|
st.call(err.unwrap_or_else(|| "Session ended".into()));
|
||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(SessionEvent::Stats(s)) => *shared.stats.lock().unwrap() = s,
|
SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s,
|
||||||
Err(_) => {
|
|
||||||
gamepad.detach();
|
|
||||||
ss.call(Screen::Hosts);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||||
|
/// operator approves this device in its console (or web UI), showing a cancelable "waiting"
|
||||||
|
/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is
|
||||||
|
/// saved as paired, so later connects are silent.
|
||||||
|
fn request_access(
|
||||||
|
ctx: &Arc<AppCtx>,
|
||||||
|
target: &Target,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||||
|
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||||
|
let pin = target.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||||
|
// A fresh cancel flag per request, installed where the waiting screen's Cancel button can read
|
||||||
|
// it back; this request's event loop captures the same `Arc` (via ConnectOpts) below.
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
*ctx.shared.cancel.lock().unwrap() = Some(cancel.clone());
|
||||||
|
connect_with(
|
||||||
|
ctx,
|
||||||
|
target,
|
||||||
|
pin,
|
||||||
|
set_screen,
|
||||||
|
set_status,
|
||||||
|
ConnectOpts {
|
||||||
|
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||||
|
// approval still lands on this connection rather than timing the client out first.
|
||||||
|
connect_timeout: Duration::from_secs(185),
|
||||||
|
persist_paired: true,
|
||||||
|
awaiting_approval: true,
|
||||||
|
cancel: Some(cancel),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cancelable "waiting for approval" screen (request-access flow): a spinner + guidance while
|
||||||
|
/// the identified connect sits parked on the host, plus a Cancel that returns to the host list and
|
||||||
|
/// trips the shared cancel flag so the parked connect tears down silently if it resolves after the
|
||||||
|
/// user has walked away. Mirrors the inline `Connecting` screen; uses no hooks.
|
||||||
|
fn request_access_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||||
|
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
||||||
|
let headline = if target_name.is_empty() {
|
||||||
|
"Waiting for approval\u{2026}".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Waiting for {target_name} to approve\u{2026}")
|
||||||
|
};
|
||||||
|
let cancel_btn = {
|
||||||
|
let (ctx, ss) = (ctx.clone(), set_screen.clone());
|
||||||
|
button("Cancel")
|
||||||
|
.icon(SymbolGlyph::Cancel)
|
||||||
|
.on_click(move || {
|
||||||
|
// Return the UI immediately; the parked connect is blocking with no abort, so trip
|
||||||
|
// the flag this request's event loop captured — it then tears down silently when
|
||||||
|
// the connect finally resolves (see ConnectOpts::cancel).
|
||||||
|
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
|
||||||
|
c.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
ss.call(Screen::Hosts);
|
||||||
|
})
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
|
};
|
||||||
|
vstack((
|
||||||
|
ProgressRing::indeterminate()
|
||||||
|
.width(48.0)
|
||||||
|
.height(48.0)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
|
text_block(headline)
|
||||||
|
.font_size(18.0)
|
||||||
|
.semibold()
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
|
text_block(
|
||||||
|
"Approve this device in the host's console or web UI \u{2014} it connects automatically \
|
||||||
|
once you approve it. No PIN needed.",
|
||||||
|
)
|
||||||
|
.foreground(ThemeRef::SecondaryText)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center),
|
||||||
|
cancel_btn,
|
||||||
|
))
|
||||||
|
.spacing(16.0)
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||||
let ctx = &props.ctx;
|
let ctx = &props.ctx;
|
||||||
let set_screen = &props.set_screen;
|
let set_screen = &props.set_screen;
|
||||||
@@ -728,6 +907,20 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
.icon(SymbolGlyph::Cancel)
|
.icon(SymbolGlyph::Cancel)
|
||||||
.on_click(move || ss.call(Screen::Hosts))
|
.on_click(move || ss.call(Screen::Hosts))
|
||||||
};
|
};
|
||||||
|
// The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that
|
||||||
|
// the host parks until the operator approves this device in its console (delegated approval).
|
||||||
|
let request_btn = {
|
||||||
|
let (ctx2, ss, st, target2) = (
|
||||||
|
ctx.clone(),
|
||||||
|
set_screen.clone(),
|
||||||
|
set_status.clone(),
|
||||||
|
target.clone(),
|
||||||
|
);
|
||||||
|
button("Request access without a PIN")
|
||||||
|
.icon(SymbolGlyph::Send)
|
||||||
|
.on_click(move || request_access(&ctx2, &target2, &ss, &st))
|
||||||
|
.horizontal_alignment(HorizontalAlignment::Stretch)
|
||||||
|
};
|
||||||
|
|
||||||
let content = card(vstack((
|
let content = card(vstack((
|
||||||
grid((
|
grid((
|
||||||
@@ -760,6 +953,13 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
.font_size(28.0)
|
.font_size(28.0)
|
||||||
.on_changed(move |s| set_code.call(s)),
|
.on_changed(move |s| set_code.call(s)),
|
||||||
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
||||||
|
text_block(
|
||||||
|
"Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
|
||||||
|
(its console or web UI) \u{2014} no PIN needed.",
|
||||||
|
)
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
request_btn,
|
||||||
))
|
))
|
||||||
.spacing(16.0))
|
.spacing(16.0))
|
||||||
.max_width(480.0)
|
.max_width(480.0)
|
||||||
@@ -967,6 +1167,21 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
.spacing(10.0),
|
.spacing(10.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let licenses_button = {
|
||||||
|
let ss = set_screen.clone();
|
||||||
|
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
|
||||||
|
};
|
||||||
|
let about_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("About").font_size(15.0).semibold(),
|
||||||
|
text_block("punktfunk is licensed under MIT OR Apache-2.0.")
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
licenses_button,
|
||||||
|
))
|
||||||
|
.spacing(10.0),
|
||||||
|
);
|
||||||
|
|
||||||
page(vec![
|
page(vec![
|
||||||
header.into(),
|
header.into(),
|
||||||
section("DISPLAY"),
|
section("DISPLAY"),
|
||||||
@@ -975,6 +1190,77 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
video_card.into(),
|
video_card.into(),
|
||||||
section("AUDIO"),
|
section("AUDIO"),
|
||||||
audio_card.into(),
|
audio_card.into(),
|
||||||
|
section("ABOUT"),
|
||||||
|
about_card.into(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static screen: the app's own license + the third-party software notices (reached from Settings).
|
||||||
|
fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||||
|
let header = grid((
|
||||||
|
text_block("Third-party licenses")
|
||||||
|
.font_size(30.0)
|
||||||
|
.bold()
|
||||||
|
.grid_column(0)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
button("Back")
|
||||||
|
.accent()
|
||||||
|
.icon(SymbolGlyph::Back)
|
||||||
|
.on_click({
|
||||||
|
let ss = set_screen.clone();
|
||||||
|
move || ss.call(Screen::Settings)
|
||||||
|
})
|
||||||
|
.grid_column(1)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
))
|
||||||
|
.columns([GridLength::Star(1.0), GridLength::Auto])
|
||||||
|
.margin(edges(0.0, 0.0, 0.0, 6.0));
|
||||||
|
|
||||||
|
let app_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("punktfunk").font_size(15.0).semibold(),
|
||||||
|
text_block("Licensed under MIT OR Apache-2.0, at your option.")
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
text_block(APP_LICENSE)
|
||||||
|
.font_size(11.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(8.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let natives_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("Bundled components").font_size(15.0).semibold(),
|
||||||
|
text_block(
|
||||||
|
"FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \
|
||||||
|
license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \
|
||||||
|
Windows App SDK (Microsoft) are also linked.",
|
||||||
|
)
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(8.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let notices_card = card(
|
||||||
|
vstack((
|
||||||
|
text_block("Rust crates").font_size(15.0).semibold(),
|
||||||
|
text_block(THIRD_PARTY_NOTICES)
|
||||||
|
.font_size(11.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(8.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
page(vec![
|
||||||
|
header.into(),
|
||||||
|
section("PUNKTFUNK"),
|
||||||
|
app_card.into(),
|
||||||
|
section("BUNDLED"),
|
||||||
|
natives_card.into(),
|
||||||
|
section("OPEN SOURCE"),
|
||||||
|
notices_card.into(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+108
-27
@@ -169,6 +169,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
|||||||
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
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,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -240,6 +247,9 @@ struct Worker {
|
|||||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||||
last_axis: [i32; 6],
|
last_axis: [i32; 6],
|
||||||
held_buttons: Vec<u32>,
|
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],
|
last_accel: [i16; 3],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,13 +262,21 @@ impl Worker {
|
|||||||
|
|
||||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||||
let pad = self.opened.get(&id)?;
|
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 {
|
Some(PadInfo {
|
||||||
id,
|
id,
|
||||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||||
pref: pref_for_type(
|
pref,
|
||||||
self.subsystem
|
|
||||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,9 +292,33 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
*v = i32::MIN;
|
*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 {
|
} else {
|
||||||
self.held_buttons.clear();
|
self.held_buttons.clear();
|
||||||
self.last_axis = [i32::MIN; 6];
|
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)]
|
#[allow(clippy::too_many_lines)]
|
||||||
@@ -305,6 +397,10 @@ fn run(
|
|||||||
// thread.
|
// thread.
|
||||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||||
|
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||||
|
// 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 sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||||
@@ -317,6 +413,7 @@ fn run(
|
|||||||
attached: None,
|
attached: None,
|
||||||
last_axis: [i32::MIN; 6],
|
last_axis: [i32::MIN; 6],
|
||||||
held_buttons: Vec::new(),
|
held_buttons: Vec::new(),
|
||||||
|
held_touches: std::collections::HashSet::new(),
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -426,9 +523,11 @@ fn run(
|
|||||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
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 {
|
Event::ControllerTouchpadDown {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -436,41 +535,23 @@ fn run(
|
|||||||
}
|
}
|
||||||
| Event::ControllerTouchpadMotion {
|
| Event::ControllerTouchpadMotion {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
..
|
..
|
||||||
} if active == Some(which) && w.attached.is_some() => {
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
let _ = w
|
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||||
.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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Event::ControllerTouchpadUp {
|
Event::ControllerTouchpadUp {
|
||||||
which,
|
which,
|
||||||
|
touchpad,
|
||||||
finger,
|
finger,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
..
|
..
|
||||||
} if active == Some(which) && w.attached.is_some() => {
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
let _ = w
|
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||||
.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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Motion: accel events update the cache; each gyro event ships a sample (the
|
// 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
|
// DualSense reports both at ~250 Hz). Scale convention shared with the other
|
||||||
|
|||||||
@@ -184,6 +184,9 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
|||||||
decoder,
|
decoder,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
|
// Headless CLI uses the normal (short) handshake budget; the long request-access wait is a
|
||||||
|
// GUI-only flow.
|
||||||
|
connect_timeout: Duration::from_secs(15),
|
||||||
});
|
});
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(60);
|
let deadline = Instant::now() + Duration::from_secs(60);
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ pub struct SessionParams {
|
|||||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
|
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||||
|
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||||
|
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||||
|
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||||
|
pub connect_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default, PartialEq)]
|
#[derive(Clone, Copy, Default, PartialEq)]
|
||||||
@@ -164,7 +169,7 @@ fn pump(
|
|||||||
None, // launch: the Windows client has no library picker yet
|
None, // launch: the Windows client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
Duration::from_secs(15),
|
params.connect_timeout,
|
||||||
) {
|
) {
|
||||||
Ok(c) => Arc::new(c),
|
Ok(c) => Arc::new(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -80,7 +80,14 @@ pub mod control {
|
|||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub refresh_hz: 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
|
/// `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_low: u32,
|
||||||
pub adapter_luid_high: i32,
|
pub adapter_luid_high: i32,
|
||||||
pub target_id: u32,
|
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.
|
/// `IOCTL_REMOVE` input.
|
||||||
@@ -129,11 +140,13 @@ pub mod control {
|
|||||||
assert!(offset_of!(AddRequest, width) == 8);
|
assert!(offset_of!(AddRequest, width) == 8);
|
||||||
assert!(offset_of!(AddRequest, height) == 12);
|
assert!(offset_of!(AddRequest, height) == 12);
|
||||||
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
||||||
|
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
|
||||||
|
|
||||||
assert!(size_of::<AddReply>() == 16);
|
assert!(size_of::<AddReply>() == 16);
|
||||||
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
||||||
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
||||||
assert!(offset_of!(AddReply, target_id) == 8);
|
assert!(offset_of!(AddReply, target_id) == 8);
|
||||||
|
assert!(offset_of!(AddReply, resolved_monitor_id) == 12);
|
||||||
|
|
||||||
assert!(size_of::<RemoveRequest>() == 8);
|
assert!(size_of::<RemoveRequest>() == 8);
|
||||||
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||||
@@ -436,11 +449,25 @@ mod tests {
|
|||||||
width: 3840,
|
width: 3840,
|
||||||
height: 2160,
|
height: 2160,
|
||||||
refresh_hz: 120,
|
refresh_hz: 120,
|
||||||
_reserved: 0,
|
preferred_monitor_id: 7,
|
||||||
};
|
};
|
||||||
let bytes = bytemuck::bytes_of(&req);
|
let bytes = bytemuck::bytes_of(&req);
|
||||||
assert_eq!(bytes.len(), 24);
|
assert_eq!(bytes.len(), 24);
|
||||||
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -492,6 +492,10 @@ pub const PUNKTFUNK_HIDOUT_LED: u8 = 1;
|
|||||||
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
||||||
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
||||||
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
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).
|
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||||
|
|
||||||
@@ -559,6 +563,23 @@ impl PunktfunkHidOutput {
|
|||||||
out.effect[..n].copy_from_slice(&effect[..n]);
|
out.effect[..n].copy_from_slice(&effect[..n]);
|
||||||
out.effect_len = n as u8;
|
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
|
out
|
||||||
}
|
}
|
||||||
@@ -618,6 +639,11 @@ impl PunktfunkHdrMeta {
|
|||||||
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
||||||
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||||
pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
|
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
|
/// 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`
|
/// ([`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.
|
/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
|
||||||
#[cfg(feature = "quic")]
|
#[cfg(feature = "quic")]
|
||||||
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
|
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
|
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||||
/// hosts); otherwise the host falls back to X-Box 360.
|
/// hosts); otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
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`.
|
/// 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
|
/// 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).
|
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||||
const _: () = {
|
const _: () = {
|
||||||
use crate::config::GamepadPref;
|
use crate::config::GamepadPref;
|
||||||
|
use crate::input::gamepad as g;
|
||||||
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.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_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.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_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
|
/// 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
|
/// The currently active session mode — the Welcome's, until an accepted
|
||||||
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
/// [`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
|
/// 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);
|
/// 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
|
/// 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
|
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||||
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
/// `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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
pub enum GamepadPref {
|
pub enum GamepadPref {
|
||||||
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
/// 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
|
/// 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.
|
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
|
||||||
DualShock4,
|
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 {
|
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 {
|
pub const fn to_u8(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
GamepadPref::Auto => 0,
|
GamepadPref::Auto => 0,
|
||||||
@@ -166,6 +176,8 @@ impl GamepadPref {
|
|||||||
GamepadPref::DualSense => 2,
|
GamepadPref::DualSense => 2,
|
||||||
GamepadPref::XboxOne => 3,
|
GamepadPref::XboxOne => 3,
|
||||||
GamepadPref::DualShock4 => 4,
|
GamepadPref::DualShock4 => 4,
|
||||||
|
GamepadPref::SteamController => 5,
|
||||||
|
GamepadPref::SteamDeck => 6,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +189,8 @@ impl GamepadPref {
|
|||||||
2 => GamepadPref::DualSense,
|
2 => GamepadPref::DualSense,
|
||||||
3 => GamepadPref::XboxOne,
|
3 => GamepadPref::XboxOne,
|
||||||
4 => GamepadPref::DualShock4,
|
4 => GamepadPref::DualShock4,
|
||||||
|
5 => GamepadPref::SteamController,
|
||||||
|
6 => GamepadPref::SteamDeck,
|
||||||
_ => GamepadPref::Auto,
|
_ => GamepadPref::Auto,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,12 +206,14 @@ impl GamepadPref {
|
|||||||
GamepadPref::XboxOne
|
GamepadPref::XboxOne
|
||||||
}
|
}
|
||||||
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
|
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
|
||||||
|
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
|
||||||
|
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
||||||
/// `"dualshock4"`).
|
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`).
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
GamepadPref::Auto => "auto",
|
GamepadPref::Auto => "auto",
|
||||||
@@ -205,6 +221,8 @@ impl GamepadPref {
|
|||||||
GamepadPref::DualSense => "dualsense",
|
GamepadPref::DualSense => "dualsense",
|
||||||
GamepadPref::XboxOne => "xboxone",
|
GamepadPref::XboxOne => "xboxone",
|
||||||
GamepadPref::DualShock4 => "dualshock4",
|
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
|
c.fec.fec_percent = 15; // 250 + ceil(250*15/100)=288 > 255
|
||||||
assert!(c.validate().is_err());
|
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_B: u32 = 0x2000;
|
||||||
pub const BTN_X: u32 = 0x4000;
|
pub const BTN_X: u32 = 0x4000;
|
||||||
pub const BTN_Y: u32 = 0x8000;
|
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`
|
/// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||||
/// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
/// 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.
|
/// the same bit. Only the DualSense backend renders it; the xpad has no such button.
|
||||||
pub const BTN_TOUCHPAD: u32 = 0x10_0000;
|
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`.
|
/// Axis ids for `InputKind::GamepadAxis`.
|
||||||
pub const AXIS_LS_X: u32 = 0;
|
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_TOUCHPAD: u8 = 0x01;
|
||||||
const RICH_MOTION: u8 = 0x02;
|
const RICH_MOTION: u8 = 0x02;
|
||||||
|
const RICH_TOUCHPAD_EX: u8 = 0x03;
|
||||||
|
|
||||||
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
|
/// 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
|
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
|
||||||
@@ -1241,6 +1242,22 @@ pub enum RichInput {
|
|||||||
gyro: [i16; 3],
|
gyro: [i16; 3],
|
||||||
accel: [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 {
|
impl RichInput {
|
||||||
@@ -1264,6 +1281,22 @@ impl RichInput {
|
|||||||
out.extend_from_slice(&v.to_le_bytes());
|
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
|
out
|
||||||
}
|
}
|
||||||
@@ -1288,6 +1321,16 @@ impl RichInput {
|
|||||||
accel: [i16at(9), i16at(11), i16at(13)],
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1296,6 +1339,7 @@ impl RichInput {
|
|||||||
const HIDOUT_LED: u8 = 0x01;
|
const HIDOUT_LED: u8 = 0x01;
|
||||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
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).
|
/// 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;
|
/// 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
|
/// 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 parameter block (mode + params) for the client to replay on a real controller.
|
||||||
Trigger { pad: u8, which: u8, effect: Vec<u8> },
|
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 {
|
impl HidOutput {
|
||||||
@@ -1325,6 +1379,18 @@ impl HidOutput {
|
|||||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||||
out.extend_from_slice(effect);
|
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
|
out
|
||||||
}
|
}
|
||||||
@@ -1349,6 +1415,13 @@ impl HidOutput {
|
|||||||
which: b[3],
|
which: b[3],
|
||||||
effect: b[4..].to_vec(),
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2486,6 +2559,16 @@ mod tests {
|
|||||||
gyro: [-100, 200, -300],
|
gyro: [-100, 200, -300],
|
||||||
accel: [16384, -8192, 1],
|
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();
|
let d = ev.encode();
|
||||||
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||||
@@ -2494,7 +2577,8 @@ mod tests {
|
|||||||
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
||||||
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_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, 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
|
// short
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2516,6 +2600,13 @@ mod tests {
|
|||||||
which: 1,
|
which: 1,
|
||||||
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
||||||
},
|
},
|
||||||
|
HidOutput::TrackpadHaptic {
|
||||||
|
pad: 0,
|
||||||
|
side: 1,
|
||||||
|
amplitude: 0x1234,
|
||||||
|
period: 0x5678,
|
||||||
|
count: 9,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
for ev in &cases {
|
for ev in &cases {
|
||||||
let d = ev.encode();
|
let d = ev.encode();
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ base64 = "0.22"
|
|||||||
ureq = "2"
|
ureq = "2"
|
||||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||||
x509-parser = "0.16"
|
x509-parser = "0.16"
|
||||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
|
||||||
|
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
|
||||||
|
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
|
||||||
|
axum-server = "0.8"
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
rustls-pemfile = "2"
|
|
||||||
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
|
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
|
||||||
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
|
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
|
||||||
# verified fingerprint injected as a request extension. Versions match the workspace lock.
|
# verified fingerprint injected as a request extension. Versions match the workspace lock.
|
||||||
@@ -87,6 +89,9 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
|||||||
wayland-client = "0.31"
|
wayland-client = "0.31"
|
||||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||||
wayland-protocols-misc = { 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
|
# 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).
|
# 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.
|
# `wayland-backend` is referenced by the generated interface tables.
|
||||||
@@ -117,6 +122,10 @@ ash = "0.38"
|
|||||||
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
|
# `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`.
|
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
|
||||||
libloading = "0.8"
|
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]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
|
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
|
||||||
@@ -173,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
|
# 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.
|
# orphans the SYSTEM host it launched into the interactive session.
|
||||||
"Win32_System_JobObjects",
|
"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
|
# 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
|
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||||
@@ -217,6 +229,7 @@ bytemuck = { version = "1.19", features = ["derive"] }
|
|||||||
# nvEncodeAPI64.dll) on the linker path. Build the GPU host with `--features nvenc`.
|
# nvEncodeAPI64.dll) on the linker path. Build the GPU host with `--features nvenc`.
|
||||||
nvenc = ["dep:nvidia-video-codec-sdk"]
|
nvenc = ["dep:nvidia-video-codec-sdk"]
|
||||||
# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a
|
# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a
|
||||||
# `FFMPEG_DIR` (BtbN gpl-shared, includes `*_amf`/`*_qsv`) at build time and bundles the FFmpeg
|
# `FFMPEG_DIR` (BtbN lgpl-shared — includes `*_amf`/`*_qsv`; the GPL-only x264/x265 are never used,
|
||||||
# DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`.
|
# so the LGPL build suffices and keeps the bundled DLLs LGPL, not GPL) at build time and bundles the
|
||||||
|
# FFmpeg DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`.
|
||||||
amf-qsv = ["dep:ffmpeg-next"]
|
amf-qsv = ["dep:ffmpeg-next"]
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
|
||||||
|
audio_control::ensure_wired_once();
|
||||||
wasapi_cap::WasapiLoopbackCapturer::open(channels)
|
wasapi_cap::WasapiLoopbackCapturer::open(channels)
|
||||||
.map(|c| Box::new(c) as Box<dyn AudioCapturer>)
|
.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")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
|
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>)
|
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")
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
mod linux;
|
||||||
#[cfg(target_os = "windows")]
|
#[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.
|
//! **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`):
|
//! 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
|
//! VB-Audio "CABLE Input" (bundled by the installer — the preferred, dedicated mic target), the
|
||||||
//! "CABLE Input", VoiceMeeter, or anything with "virtual" in the name. If none is present we
|
//! "Steam Streaming Microphone", VoiceMeeter, or anything with "virtual" in the name.
|
||||||
//! auto-install the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we
|
//! [`super::audio_control`] sets the default playback to a DIFFERENT loopback-capable device so the
|
||||||
//! return an error with install guidance and the host runs without mic passthrough.
|
//! 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
|
//! **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
|
//! ([`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
|
/// Render-endpoint friendly-name substrings (lowercased) we can write into so the device's capture
|
||||||
/// endpoint becomes a host mic. Ordered by preference.
|
/// endpoint becomes a host mic. Ordered by preference.
|
||||||
const CANDIDATES: &[&str] = &[
|
const CANDIDATES: &[&str] = &[
|
||||||
|
"cable input", // VB-Audio Virtual Cable — bundled by the installer; the preferred dedicated mic target
|
||||||
"steam streaming microphone",
|
"steam streaming microphone",
|
||||||
"cable input",
|
|
||||||
"voicemeeter input",
|
"voicemeeter input",
|
||||||
"voicemeeter aux input",
|
"voicemeeter aux input",
|
||||||
"virtual",
|
"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
|
/// 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.
|
/// staging. `false` **only** for the GPU-less software encoder.
|
||||||
pub gpu: bool,
|
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.
|
/// `false` = 8-bit SDR.
|
||||||
pub hdr: bool,
|
pub hdr: bool,
|
||||||
/// Full-chroma 4:4:4 session: the capturer must keep full chroma — deliver packed **RGB**
|
/// 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>)
|
.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")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn capture_virtual_output(
|
pub fn capture_virtual_output(
|
||||||
vout: crate::vdisplay::VirtualOutput,
|
vout: crate::vdisplay::VirtualOutput,
|
||||||
want: OutputFormat,
|
want: OutputFormat,
|
||||||
capture: crate::session_plan::CaptureBackend,
|
_capture: crate::session_plan::CaptureBackend,
|
||||||
) -> Result<Box<dyn Capturer>> {
|
) -> Result<Box<dyn Capturer>> {
|
||||||
use crate::session_plan::CaptureBackend;
|
|
||||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
||||||
@@ -404,97 +393,36 @@ pub fn capture_virtual_output(
|
|||||||
})?;
|
})?;
|
||||||
let pref = vout.preferred_mode;
|
let pref = vout.preferred_mode;
|
||||||
let keep = vout.keepalive;
|
let keep = vout.keepalive;
|
||||||
// Full-chroma 4:4:4 needs a full-chroma RGB source. The IDD-push and WGC paths emit subsampled
|
// IDD direct-push is the sole Windows capture path: consume frames straight from the pf-vdisplay
|
||||||
// NV12/P010 by default, which can't reconstruct 4:4:4; route a 4:4:4 session to DDA, which delivers
|
// driver's shared ring (in-process, Session 0 — it captures the secure desktop too; no Desktop
|
||||||
// RGB (Bgra) when its `chroma_444` flag is set. (IDD-push/WGC 4:4:4 capture is a follow-up.)
|
// Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's
|
||||||
if want.chroma_444 && capture != CaptureBackend::Dda {
|
// swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display
|
||||||
tracing::info!("4:4:4 session — using DDA capture (RGB source) instead of {capture:?}");
|
// is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and
|
||||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
|
// selects the per-frame conversion (FP16 → P010 vs BGRA → NV12). `IddPushCapturer` takes the
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
// 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.
|
||||||
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared
|
idd_push::IddPushCapturer::open(target, pref, want.hdr, keep)
|
||||||
// ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
// (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
|
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
|
||||||
// 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
|
/// Whether the active capturer can deliver a full-chroma (RGB) source for a 4:4:4 HEVC encode. The
|
||||||
// working IddCx swap-chain, whereas a REUSED monitor's swap-chain dies after ~2 sessions and
|
/// negotiator gates 4:4:4 on this so the host honestly downgrades to 4:2:0 when the capturer can only
|
||||||
// the host can't revive it. The driver's recreate crash (target id resolved to 0) is fixed by
|
/// produce subsampled frames. Linux (the portal capturer feeding CPU RGB → `yuv444p`) can; the Windows
|
||||||
// stamping target_id onto the monitor context. The ring is always FP16 (the driver composes
|
/// IDD-push path delivers subsampled NV12/P010 today, so full-chroma capture there is a follow-up.
|
||||||
// the IDD in FP16); `want_hdr` selects the per-frame conversion (FP16 → Rgb10a2 vs Bgra).
|
#[cfg(target_os = "linux")]
|
||||||
// If IDD-push can't open OR the driver doesn't attach to the ring within a few seconds (e.g. a
|
pub(crate) fn capturer_supports_444() -> bool {
|
||||||
// hybrid-GPU render mismatch), fall back to DDA so the session is NEVER left black (audit §5.1).
|
true
|
||||||
// `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) {
|
#[cfg(target_os = "windows")]
|
||||||
Ok(c) => return Ok(Box::new(c) as Box<dyn Capturer>),
|
pub(crate) fn capturer_supports_444() -> bool {
|
||||||
Err((e, keep)) => {
|
// IDD-push 4:4:4 (full-chroma RGB from the FP16 ring) is the next step; until then the sole Windows
|
||||||
tracing::warn!(
|
// capturer delivers subsampled NV12/P010 only, so the host honestly negotiates 4:2:0.
|
||||||
error = %format!("{e:#}"),
|
false
|
||||||
"IDD-push open/attach failed — falling back to DDA"
|
}
|
||||||
);
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
return dxgi::DuplCapturer::open(
|
pub(crate) fn capturer_supports_444() -> bool {
|
||||||
target,
|
false
|
||||||
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>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[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")
|
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/`
|
// 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).
|
// (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged). Windows capture
|
||||||
#[cfg(target_os = "windows")]
|
// is IDD direct-push only — DXGI Desktop Duplication (DDA) and the WGC two-process relay were removed.
|
||||||
#[path = "capture/windows/composed_flip.rs"]
|
|
||||||
pub mod composed_flip;
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
#[path = "capture/windows/desktop_watch.rs"]
|
|
||||||
pub mod desktop_watch;
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "capture/windows/dxgi.rs"]
|
#[path = "capture/windows/dxgi.rs"]
|
||||||
pub mod dxgi;
|
pub mod dxgi;
|
||||||
@@ -522,9 +445,3 @@ pub mod dxgi;
|
|||||||
pub mod idd_push;
|
pub mod idd_push;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod 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
|
//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver's WUDFHost canNOT create named
|
||||||
//! token that canNOT create named kernel objects, so — exactly like the gamepad UMDF drivers
|
//! kernel objects, so — exactly like the gamepad UMDF drivers (`inject/dualsense_windows.rs`) — the
|
||||||
//! (`inject/dualsense_windows.rs`) — the HOST (privileged) CREATES the shared header + frame-ready
|
//! HOST (privileged) CREATES the shared header + frame-ready event + ring of keyed-mutex textures
|
||||||
//! event + ring of keyed-mutex textures (`Global\` names, permissive `D:(A;;GA;;;WD)` SDDL) on the
|
//! (`Global\` names, scoped `D:(A;;GA;;;SY)(A;;GA;;;LS)` to SYSTEM + the driver's LocalService host —
|
||||||
//! discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring
|
//! 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
|
//! 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/
|
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
|
||||||
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
||||||
@@ -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`.
|
// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`.
|
||||||
unsafe impl Send for IddPushCapturer {}
|
unsafe impl Send for IddPushCapturer {}
|
||||||
|
|
||||||
/// Build a permissive (Everyone:GenericAll) `SECURITY_ATTRIBUTES` so the restricted WUDFHost driver
|
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM** (the host creates+publishes the
|
||||||
/// can OPEN the host-created objects — the same `D:(A;;GA;;;WD)` SDDL the gamepad shared section uses.
|
/// shared event + texture ring) and **LocalService** (the account the pf_vdisplay WUDFHost runs under,
|
||||||
/// The returned `psd` backing must outlive `sa`; both are dropped when the process exits.
|
/// which consumes them) — `D:(A;;GA;;;SY)(A;;GA;;;LS)`, the same scoping as the gamepad section. The
|
||||||
unsafe fn permissive_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
|
/// 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();
|
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
w!("D:(A;;GA;;;WD)"),
|
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"),
|
||||||
SDDL_REVISION_1,
|
SDDL_REVISION_1,
|
||||||
&mut psd,
|
&mut psd,
|
||||||
None,
|
None,
|
||||||
@@ -269,7 +273,7 @@ impl IddPushCapturer {
|
|||||||
h: u32,
|
h: u32,
|
||||||
format: DXGI_FORMAT,
|
format: DXGI_FORMAT,
|
||||||
) -> Result<Vec<HostSlot>> {
|
) -> Result<Vec<HostSlot>> {
|
||||||
let (sa, _psd) = permissive_sa()?;
|
let (sa, _psd) = shared_object_sa()?;
|
||||||
let mut slots = Vec::new();
|
let mut slots = Vec::new();
|
||||||
for k in 0..RING_LEN {
|
for k in 0..RING_LEN {
|
||||||
let desc = D3D11_TEXTURE2D_DESC {
|
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:
|
// 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
|
// - `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.
|
// `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
|
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned
|
||||||
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names
|
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names
|
||||||
// are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid
|
// 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")?;
|
.context("EnumAdapterByLuid(render adapter) for IDD push")?;
|
||||||
let (device, context) = make_device(&adapter).context("make_device 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);
|
let bytes = std::mem::size_of::<SharedHeader>().max(64);
|
||||||
|
|
||||||
// Header.
|
// 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
|
//! **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`,
|
//! 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
|
//! `encoder_pref`, `render_adapter`, the vdisplay backend select — plus the plan-named
|
||||||
//! plan-named `secure_dda`/`idd_depth`/`zerocopy`/`ten_bit`/`four_four_four` and the multi-site `perf`/`compositor`/
|
//! `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
|
//! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the
|
||||||
//! capture/topology/encoder decision.
|
//! 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.
|
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct HostConfig {
|
pub struct HostConfig {
|
||||||
/// `PUNKTFUNK_IDD_PUSH` — capture from the pf-vdisplay driver's shared ring (in-process Session-0
|
/// `PUNKTFUNK_IDD_PUSH` — IDD direct-push monitor mode (the per-session monitor + ring recreate and
|
||||||
/// capture; no WGC helper). **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on); unset ⇒ off.
|
/// the discrete-render-GPU pin in [`crate::vdisplay::manager`]). IDD-push is the sole Windows capture
|
||||||
/// The installer's default `host.env` sets it on, so a fresh install runs the validated IDD-push path
|
/// path (DXGI Desktop Duplication and the WGC relay were removed), so this should stay on — the
|
||||||
/// (it falls back to DDA if the driver can't attach — see [`crate::capture`]). NOT a bare presence flag
|
/// installer's `host.env` sets it. **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on);
|
||||||
/// (so an operator can turn it OFF in `host.env` with `=0`, which a `var_os` presence check can't).
|
/// unset ⇒ off. NOT a bare presence flag (so an operator can turn it OFF with `=0`).
|
||||||
pub idd_push: bool,
|
pub idd_push: bool,
|
||||||
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
|
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
|
||||||
pub encoder_pref: String,
|
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:
|
/// `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).
|
/// the empty string still counts as "set" for the presence checks, and the value reader filters it).
|
||||||
pub render_adapter: Option<String>,
|
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`).
|
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
|
||||||
pub idd_depth: usize,
|
pub idd_depth: usize,
|
||||||
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
|
/// `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")
|
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase(),
|
.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"),
|
render_adapter: val("PUNKTFUNK_RENDER_ADAPTER"),
|
||||||
secure_dda: flag("PUNKTFUNK_SECURE_DDA"),
|
|
||||||
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
.unwrap_or(2),
|
.unwrap_or(2),
|
||||||
|
|||||||
@@ -230,6 +230,14 @@ pub fn open_video(
|
|||||||
chroma: ChromaFormat,
|
chroma: ChromaFormat,
|
||||||
) -> Result<Box<dyn Encoder>> {
|
) -> Result<Box<dyn Encoder>> {
|
||||||
validate_dimensions(codec, width, height)?;
|
validate_dimensions(codec, width, height)?;
|
||||||
|
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||||||
|
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||||||
|
// The mid-stream Reconfigure path already guards `refresh_hz > 0`; enforcing it at this single
|
||||||
|
// open chokepoint makes EVERY path (initial Hello, GameStream ANNOUNCE, Reconfigure) safe
|
||||||
|
// regardless of which backend opens (security-review 2026-06-28 S5).
|
||||||
|
if fps == 0 || fps > 1000 {
|
||||||
|
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
|
||||||
|
}
|
||||||
// 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on
|
// 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on
|
||||||
// `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
|
// `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
|
||||||
// no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
|
// no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ pub struct AppEntry {
|
|||||||
pub compositor: Option<crate::vdisplay::Compositor>,
|
pub compositor: Option<crate::vdisplay::Compositor>,
|
||||||
/// Command gamescope runs nested (gamescope entries only).
|
/// Command gamescope runs nested (gamescope entries only).
|
||||||
pub cmd: Option<String>,
|
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> {
|
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
|
/// The GameStream catalog Moonlight sees in `/applist`: the operator base ([`base_catalog`] — Desktop +
|
||||||
/// entries when gamescope is installed).
|
/// 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> {
|
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 Some(path) = config_path() {
|
||||||
if let Ok(raw) = std::fs::read_to_string(&path) {
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
||||||
match serde_json::from_str::<Value>(&raw) {
|
match serde_json::from_str::<Value>(&raw) {
|
||||||
@@ -53,6 +66,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
|||||||
.and_then(|c| c.as_str())
|
.and_then(|c| c.as_str())
|
||||||
.and_then(parse_compositor),
|
.and_then(parse_compositor),
|
||||||
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
||||||
|
library_id: None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -72,6 +86,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
|||||||
title: "Desktop".into(),
|
title: "Desktop".into(),
|
||||||
compositor: None,
|
compositor: None,
|
||||||
cmd: None,
|
cmd: None,
|
||||||
|
library_id: None,
|
||||||
}];
|
}];
|
||||||
if which("gamescope") {
|
if which("gamescope") {
|
||||||
if which("steam") {
|
if which("steam") {
|
||||||
@@ -80,6 +95,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
|||||||
title: "Steam".into(),
|
title: "Steam".into(),
|
||||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||||
cmd: Some("steam -gamepadui".into()),
|
cmd: Some("steam -gamepadui".into()),
|
||||||
|
library_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if which("vkcube") {
|
if which("vkcube") {
|
||||||
@@ -88,23 +104,79 @@ pub fn catalog() -> Vec<AppEntry> {
|
|||||||
title: "vkcube (test)".into(),
|
title: "vkcube (test)".into(),
|
||||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||||
cmd: Some("vkcube".into()),
|
cmd: Some("vkcube".into()),
|
||||||
|
library_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
apps
|
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> {
|
pub fn by_id(id: u32) -> Option<AppEntry> {
|
||||||
catalog().into_iter().find(|a| a.id == id)
|
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 {
|
pub fn applist_xml() -> String {
|
||||||
|
let hdr = u8::from(crate::gamestream::host_hdr_capable());
|
||||||
let mut xml =
|
let mut xml =
|
||||||
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
||||||
for app in catalog() {
|
for app in catalog() {
|
||||||
xml.push_str(&format!(
|
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),
|
xml_escape(&app.title),
|
||||||
app.id
|
app.id
|
||||||
));
|
));
|
||||||
@@ -130,10 +202,46 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_catalog_has_desktop() {
|
fn default_catalog_has_desktop() {
|
||||||
|
// catalog() = base (Desktop + apps.json) + the installed library; Desktop (id 1) is always present.
|
||||||
let apps = catalog();
|
let apps = catalog();
|
||||||
assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop"));
|
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]
|
#[test]
|
||||||
fn applist_xml_is_wellformed_ish() {
|
fn applist_xml_is_wellformed_ish() {
|
||||||
let xml = applist_xml();
|
let xml = applist_xml();
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
// GCM scheme detected from the first authenticating packet; reused thereafter.
|
// GCM scheme detected from the first authenticating packet; reused thereafter.
|
||||||
let mut detected: Option<Scheme> = None;
|
let mut detected: Option<Scheme> = None;
|
||||||
|
// Consecutive control-decrypt failures for this peer — throttles the warn log so a
|
||||||
|
// junk-packet flood can't spam unbounded lines (security-review 2026-06-28 #10).
|
||||||
|
let mut decrypt_fails: u64 = 0;
|
||||||
// Decoded keyboard/mouse is forwarded to a dedicated host-lifetime injector thread —
|
// Decoded keyboard/mouse is forwarded to a dedicated host-lifetime injector thread —
|
||||||
// NEVER injected inline, so a slow Wayland/libei/SendInput call can't head-block ENet
|
// NEVER injected inline, so a slow Wayland/libei/SendInput call can't head-block ENet
|
||||||
// keepalive/retransmit servicing on this thread. The injector owns non-Send compositor
|
// keepalive/retransmit servicing on this thread. The injector owns non-Send compositor
|
||||||
@@ -77,6 +80,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
Event::Disconnect { .. } => {
|
Event::Disconnect { .. } => {
|
||||||
tracing::info!("control: client disconnected");
|
tracing::info!("control: client disconnected");
|
||||||
detected = None;
|
detected = None;
|
||||||
|
decrypt_fails = 0;
|
||||||
peer = None;
|
peer = None;
|
||||||
// Unplug the session's virtual pads.
|
// Unplug the session's virtual pads.
|
||||||
pads = GamepadManager::new();
|
pads = GamepadManager::new();
|
||||||
@@ -89,6 +93,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
channel_id,
|
channel_id,
|
||||||
packet.data(),
|
packet.data(),
|
||||||
&mut detected,
|
&mut detected,
|
||||||
|
&mut decrypt_fails,
|
||||||
&inj_tx,
|
&inj_tx,
|
||||||
&mut pads,
|
&mut pads,
|
||||||
);
|
);
|
||||||
@@ -163,6 +168,7 @@ fn on_receive(
|
|||||||
_channel_id: u8,
|
_channel_id: u8,
|
||||||
d: &[u8],
|
d: &[u8],
|
||||||
detected: &mut Option<Scheme>,
|
detected: &mut Option<Scheme>,
|
||||||
|
decrypt_fails: &mut u64,
|
||||||
inj_tx: &Sender<InputEvent>,
|
inj_tx: &Sender<InputEvent>,
|
||||||
pads: &mut GamepadManager,
|
pads: &mut GamepadManager,
|
||||||
) {
|
) {
|
||||||
@@ -180,10 +186,20 @@ fn on_receive(
|
|||||||
tracing::info!(?scheme, "control: GCM scheme locked in");
|
tracing::info!(?scheme, "control: GCM scheme locked in");
|
||||||
}
|
}
|
||||||
*detected = Some(scheme);
|
*detected = Some(scheme);
|
||||||
|
*decrypt_fails = 0;
|
||||||
pt
|
pt
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(len = d.len(), "control: GCM decrypt failed");
|
// Throttle: a junk-packet flood must not spam one warn line per packet. Log the first
|
||||||
|
// failure, then only at exponentially-spaced counts (1, 2, 4, 8, …).
|
||||||
|
*decrypt_fails += 1;
|
||||||
|
if decrypt_fails.is_power_of_two() {
|
||||||
|
tracing::warn!(
|
||||||
|
len = d.len(),
|
||||||
|
fails = *decrypt_fails,
|
||||||
|
"control: GCM decrypt failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user