Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 160b67d043 | |||
| 6c4ba77606 | |||
| eeee2782f5 | |||
| b488bd1d99 | |||
| 7e6561aaa2 | |||
| e9c5030190 | |||
| 22c0d92f2e | |||
| 097cc6faf4 | |||
| 8b37badae4 | |||
| 90c2d8b3a0 | |||
| 853e7fe92f | |||
| df496776b0 | |||
| 5310176ab5 | |||
| 76ff616dcf | |||
| ac706ba839 | |||
| 94b5f48d0b | |||
| 139d032e55 | |||
| caa7a1c735 | |||
| 13dc7fc49f | |||
| 57ae00a9c8 |
@@ -14,8 +14,12 @@
|
|||||||
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
||||||
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
||||||
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
||||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements, so what we test
|
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
|
||||||
# locally equals what App Store users get.
|
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
|
||||||
|
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
|
||||||
|
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
|
||||||
|
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
|
||||||
|
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
|
||||||
#
|
#
|
||||||
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
||||||
# step is continue-on-error until they exist):
|
# step is continue-on-error until they exist):
|
||||||
@@ -27,6 +31,15 @@
|
|||||||
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
||||||
# .pkg is installer-signed with it.
|
# .pkg is installer-signed with it.
|
||||||
#
|
#
|
||||||
|
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
|
||||||
|
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
|
||||||
|
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
|
||||||
|
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
|
||||||
|
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
|
||||||
|
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
|
||||||
|
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
|
||||||
|
# a launchable app.
|
||||||
|
#
|
||||||
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
||||||
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
||||||
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
||||||
@@ -156,9 +169,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||||
# provisioning-profile gate; codesign just needs the (now valid) identity + the
|
# provisioning-profile gate at archive time; we re-assert that authorization below by
|
||||||
# team-prefixed entitlements, no profile (App Sandbox + the network/device
|
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
|
||||||
# capabilities are self-asserted for Developer ID — no profile entry needed).
|
|
||||||
# Bundle is a single static binary.
|
# Bundle is a single static binary.
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
@@ -173,6 +185,35 @@ jobs:
|
|||||||
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
||||||
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||||
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
||||||
|
|
||||||
|
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
|
||||||
|
# network/device keys are self-asserted for Developer ID, but a keychain access group
|
||||||
|
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
|
||||||
|
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
|
||||||
|
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
|
||||||
|
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
|
||||||
|
# entitlements authorize the access group, exactly like the App Store build's profile
|
||||||
|
# does. Located by profile Name among the profiles installed on the runner (see header).
|
||||||
|
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
|
||||||
|
PROFILE_SRC=""
|
||||||
|
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
|
||||||
|
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
|
||||||
|
[ -e "$p" ] || continue
|
||||||
|
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
|
||||||
|
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
|
||||||
|
done
|
||||||
|
if [ -n "$PROFILE_SRC" ]; then
|
||||||
|
# Must land BEFORE codesign so it's sealed into the bundle.
|
||||||
|
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
|
||||||
|
echo "embedded Developer ID profile: $PROFILE_SRC"
|
||||||
|
else
|
||||||
|
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
|
||||||
|
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
|
||||||
|
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
|
||||||
|
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
|
||||||
|
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
codesign --force --options runtime --timestamp \
|
codesign --force --options runtime --timestamp \
|
||||||
--entitlements "$RESOLVED" \
|
--entitlements "$RESOLVED" \
|
||||||
--sign "Developer ID Application" "$APP"
|
--sign "Developer ID Application" "$APP"
|
||||||
|
|||||||
Generated
+71
-12
@@ -1952,6 +1952,16 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "if-addrs"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "if-addrs"
|
name = "if-addrs"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
@@ -2119,7 +2129,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2195,7 +2205,7 @@ dependencies = [
|
|||||||
"cookie-factory",
|
"cookie-factory",
|
||||||
"libc",
|
"libc",
|
||||||
"libspa-sys",
|
"libspa-sys",
|
||||||
"nix",
|
"nix 0.30.1",
|
||||||
"nom 8.0.0",
|
"nom 8.0.0",
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
@@ -2251,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2262,6 +2272,16 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac_address"
|
||||||
|
version = "1.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||||
|
dependencies = [
|
||||||
|
"nix 0.29.0",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2285,7 +2305,7 @@ checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"flume",
|
"flume",
|
||||||
"if-addrs",
|
"if-addrs 0.15.0",
|
||||||
"log",
|
"log",
|
||||||
"mio",
|
"mio",
|
||||||
"socket-pktinfo",
|
"socket-pktinfo",
|
||||||
@@ -2383,6 +2403,19 @@ dependencies = [
|
|||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"memoffset",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
@@ -2742,7 +2775,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"libspa",
|
"libspa",
|
||||||
"libspa-sys",
|
"libspa-sys",
|
||||||
"nix",
|
"nix 0.30.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pipewire-sys",
|
"pipewire-sys",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -2875,7 +2908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2889,12 +2922,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
|
"khronos-egl",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"opus",
|
"opus",
|
||||||
@@ -2911,7 +2945,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2934,7 +2968,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2942,6 +2976,7 @@ dependencies = [
|
|||||||
"criterion",
|
"criterion",
|
||||||
"fec-rs",
|
"fec-rs",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"if-addrs 0.13.4",
|
||||||
"libc",
|
"libc",
|
||||||
"opus",
|
"opus",
|
||||||
"proptest",
|
"proptest",
|
||||||
@@ -2964,7 +2999,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -2982,10 +3017,12 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
"if-addrs 0.13.4",
|
||||||
"khronos-egl",
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
|
"mac_address",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"nvidia-video-codec-sdk",
|
"nvidia-video-codec-sdk",
|
||||||
"openh264",
|
"openh264",
|
||||||
@@ -3034,7 +3071,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3048,7 +3085,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -4765,6 +4802,22 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -4774,6 +4827,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ members = [
|
|||||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -124,6 +124,25 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
val identityStore = remember { IdentityStore(context) }
|
val identityStore = remember { IdentityStore(context) }
|
||||||
val knownHostStore = remember { KnownHostStore(context) }
|
val knownHostStore = remember { KnownHostStore(context) }
|
||||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||||
|
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
|
||||||
|
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
|
||||||
|
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
|
||||||
|
// was actually newly learned.
|
||||||
|
LaunchedEffect(discovered) {
|
||||||
|
val learned = withContext(Dispatchers.IO) {
|
||||||
|
var any = false
|
||||||
|
discovered.forEach { dh ->
|
||||||
|
if (dh.mac.isNotEmpty() &&
|
||||||
|
knownHostStore.get(dh.host, dh.port)?.let { it.mac != dh.mac } == true
|
||||||
|
) {
|
||||||
|
knownHostStore.learnMac(dh.host, dh.port, dh.mac)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
any
|
||||||
|
}
|
||||||
|
if (learned) savedHosts = knownHostStore.all()
|
||||||
|
}
|
||||||
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
|
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
|
||||||
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
||||||
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
||||||
@@ -176,6 +195,14 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
connecting = true
|
connecting = true
|
||||||
status = "Connecting to $targetHost:$targetPort…"
|
status = "Connecting to $targetHost:$targetPort…"
|
||||||
|
// Auto-wake: reconnecting to a saved host that may be asleep. If we learned its MAC while it
|
||||||
|
// was online and it isn't currently advertising, fire a magic packet first — the connect's
|
||||||
|
// own timeout gives a woken host time to come up (harmless if it's already awake).
|
||||||
|
knownHostStore.get(targetHost, targetPort)?.mac
|
||||||
|
?.takeIf { it.isNotEmpty() && discovered.none { d -> d.host == targetHost && d.port == targetPort } }
|
||||||
|
?.let { macs ->
|
||||||
|
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
||||||
|
}
|
||||||
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 {
|
||||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||||
@@ -359,6 +386,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
},
|
},
|
||||||
onRename = { renameTarget = kh },
|
onRename = { renameTarget = kh },
|
||||||
|
// Explicit wake: offered only when the host is offline and we have a MAC to
|
||||||
|
// target (a tap-to-connect already auto-wakes an offline saved host).
|
||||||
|
onWake = if (kh.mac.isNotEmpty() &&
|
||||||
|
discovered.none { it.host == kh.address && it.port == kh.port }
|
||||||
|
) {
|
||||||
|
{ scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address) } }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ fun HostCard(
|
|||||||
onConnect: () -> Unit,
|
onConnect: () -> Unit,
|
||||||
onForget: (() -> Unit)?,
|
onForget: (() -> Unit)?,
|
||||||
onRename: (() -> Unit)? = null,
|
onRename: (() -> Unit)? = null,
|
||||||
|
onWake: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||||
@@ -107,7 +108,7 @@ fun HostCard(
|
|||||||
StatusPill(status)
|
StatusPill(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onForget != null || onRename != null) {
|
if (onForget != null || onRename != null || onWake != null) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||||
@@ -119,6 +120,15 @@ fun HostCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
if (onWake != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Wake host") },
|
||||||
|
onClick = {
|
||||||
|
menu = false
|
||||||
|
onWake()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
if (onRename != null) {
|
if (onRename != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Rename") },
|
text = { Text("Rename") },
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ object NativeBridge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
||||||
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
* `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||||
* cheap (a lock + string build), safe to call on the main thread.
|
* cheap (a lock + string build), safe to call on the main thread.
|
||||||
*/
|
*/
|
||||||
external fun nativeDiscoveryPoll(handle: Long): String
|
external fun nativeDiscoveryPoll(handle: Long): String
|
||||||
@@ -94,6 +94,15 @@ object NativeBridge {
|
|||||||
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
||||||
external fun nativeDiscoveryStop(handle: Long)
|
external fun nativeDiscoveryStop(handle: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a Wake-on-LAN magic packet to wake a sleeping host. [macsCsv] is comma-separated MAC
|
||||||
|
* addresses (`aa:bb:..,cc:dd:..`), learned from the host's mDNS `mac` TXT while it was online;
|
||||||
|
* [lastIp] is the host's last-known IPv4 (or empty). Returns true if at least one datagram was
|
||||||
|
* sent. No handle — callable without a live session. Do NOT call on the main thread (it does
|
||||||
|
* blocking socket sends); run it on a background dispatcher.
|
||||||
|
*/
|
||||||
|
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
||||||
|
|||||||
+7
-3
@@ -17,15 +17,17 @@ data class DiscoveredHost(
|
|||||||
val port: Int,
|
val port: Int,
|
||||||
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
||||||
val pairingRequired: Boolean = false,
|
val pairingRequired: Boolean = false,
|
||||||
|
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||||
private const val FIELD_SEP = '\u001F'
|
private const val FIELD_SEP = '\u001F'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac`), or
|
||||||
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
* null if it's malformed. `mac` (7th field) is optional — an older host omits it. Pure —
|
||||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
* unit-tested without Android (see ParseRecordTest). The native side already applied the protocol
|
||||||
|
* gate and address selection, so this is just field marshaling.
|
||||||
*/
|
*/
|
||||||
fun parseHostRecord(record: String): DiscoveredHost? {
|
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||||
val f = record.split(FIELD_SEP)
|
val f = record.split(FIELD_SEP)
|
||||||
@@ -40,6 +42,8 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
|||||||
port = port,
|
port = port,
|
||||||
fingerprint = f[4].ifBlank { null },
|
fingerprint = f[4].ifBlank { null },
|
||||||
pairingRequired = f[5] == "required",
|
pairingRequired = f[5] == "required",
|
||||||
|
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
else emptyList(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ data class KnownHost(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val fpHex: String,
|
val fpHex: String,
|
||||||
val paired: Boolean,
|
val paired: Boolean,
|
||||||
|
/**
|
||||||
|
* Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was
|
||||||
|
* online, so the client can wake it once it sleeps. Empty until first learned.
|
||||||
|
*/
|
||||||
|
val mac: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +47,22 @@ class KnownHostStore(context: Context) {
|
|||||||
.put("name", host.name)
|
.put("name", host.name)
|
||||||
.put("fp", host.fpHex.lowercase())
|
.put("fp", host.fpHex.lowercase())
|
||||||
.put("paired", host.paired)
|
.put("paired", host.paired)
|
||||||
|
.put("mac", host.mac.joinToString(","))
|
||||||
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while online).
|
||||||
|
* No-op when the host isn't saved, the list is empty, or it's unchanged — so it doesn't churn
|
||||||
|
* prefs on every discovery tick.
|
||||||
|
*/
|
||||||
|
fun learnMac(address: String, port: Int, mac: List<String>) {
|
||||||
|
if (mac.isEmpty()) return
|
||||||
|
val h = get(address, port) ?: return
|
||||||
|
if (h.mac == mac) return
|
||||||
|
save(h.copy(mac = mac))
|
||||||
|
}
|
||||||
|
|
||||||
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
||||||
fun remove(address: String, port: Int) {
|
fun remove(address: String, port: Int) {
|
||||||
prefs.edit().remove(key(address, port)).apply()
|
prefs.edit().remove(key(address, port)).apply()
|
||||||
@@ -68,6 +86,7 @@ class KnownHostStore(context: Context) {
|
|||||||
name = j.getString("name"),
|
name = j.getString("name"),
|
||||||
fpHex = j.getString("fp"),
|
fpHex = j.getString("fp"),
|
||||||
paired = j.optBoolean("paired", false),
|
paired = j.optBoolean("paired", false),
|
||||||
|
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const PROTO: &str = "punktfunk/1";
|
|||||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||||
const FIELD_SEP: char = '\u{1f}';
|
const FIELD_SEP: char = '\u{1f}';
|
||||||
|
|
||||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = [`FIELD_SEP`]).
|
||||||
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||||
/// every field so no value can break it.
|
/// every field so no value can break it.
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
@@ -42,6 +42,8 @@ struct Host {
|
|||||||
port: u16,
|
port: u16,
|
||||||
fp: String,
|
fp: String,
|
||||||
pair: String,
|
pair: String,
|
||||||
|
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated), for later wake. Empty if absent.
|
||||||
|
mac: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Host {
|
impl Host {
|
||||||
@@ -54,13 +56,14 @@ impl Host {
|
|||||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||||
}
|
}
|
||||||
format!(
|
format!(
|
||||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||||
clean(&self.key),
|
clean(&self.key),
|
||||||
clean(&self.name),
|
clean(&self.name),
|
||||||
clean(&self.addr),
|
clean(&self.addr),
|
||||||
self.port,
|
self.port,
|
||||||
clean(&self.fp),
|
clean(&self.fp),
|
||||||
clean(&self.pair),
|
clean(&self.pair),
|
||||||
|
clean(&self.mac),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +185,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
|||||||
port: info.get_port(),
|
port: info.get_port(),
|
||||||
fp: val("fp"),
|
fp: val("fp"),
|
||||||
pair: val("pair"),
|
pair: val("pair"),
|
||||||
|
mac: val("mac"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +206,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
|
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts /
|
||||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||||
@@ -263,16 +267,18 @@ mod tests {
|
|||||||
port: 9777,
|
port: 9777,
|
||||||
fp: "ab".repeat(32),
|
fp: "ab".repeat(32),
|
||||||
pair: "required".into(),
|
pair: "required".into(),
|
||||||
|
mac: "aa:bb:cc:dd:ee:ff".into(),
|
||||||
};
|
};
|
||||||
let encoded = h.encode();
|
let encoded = h.encode();
|
||||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||||
assert_eq!(fields.len(), 6);
|
assert_eq!(fields.len(), 7);
|
||||||
assert_eq!(fields[0], "host-123");
|
assert_eq!(fields[0], "host-123");
|
||||||
assert_eq!(fields[1], "home-worker-2");
|
assert_eq!(fields[1], "home-worker-2");
|
||||||
assert_eq!(fields[2], "192.168.1.70");
|
assert_eq!(fields[2], "192.168.1.70");
|
||||||
assert_eq!(fields[3], "9777");
|
assert_eq!(fields[3], "9777");
|
||||||
assert_eq!(fields[4], "ab".repeat(32));
|
assert_eq!(fields[4], "ab".repeat(32));
|
||||||
assert_eq!(fields[5], "required");
|
assert_eq!(fields[5], "required");
|
||||||
|
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
|
||||||
assert!(
|
assert!(
|
||||||
!encoded.contains('\n'),
|
!encoded.contains('\n'),
|
||||||
"a record must never contain the record separator"
|
"a record must never contain the record separator"
|
||||||
@@ -282,7 +288,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||||
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
||||||
// them so the snapshot stays exactly one record of exactly six fields.
|
// them so the snapshot stays exactly one record of exactly seven fields.
|
||||||
let h = Host {
|
let h = Host {
|
||||||
key: "k\u{1f}injected".into(),
|
key: "k\u{1f}injected".into(),
|
||||||
name: "evil\nhost\r".into(),
|
name: "evil\nhost\r".into(),
|
||||||
@@ -290,9 +296,14 @@ mod tests {
|
|||||||
port: 9777,
|
port: 9777,
|
||||||
fp: "ab\u{1f}cd".into(),
|
fp: "ab\u{1f}cd".into(),
|
||||||
pair: "required\n".into(),
|
pair: "required\n".into(),
|
||||||
|
mac: "aa:bb\u{1f}cc".into(),
|
||||||
};
|
};
|
||||||
let encoded = h.encode();
|
let encoded = h.encode();
|
||||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
assert_eq!(
|
||||||
|
encoded.matches(FIELD_SEP).count(),
|
||||||
|
6,
|
||||||
|
"exactly seven fields"
|
||||||
|
);
|
||||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||||
assert_eq!(fields[0], "kinjected");
|
assert_eq!(fields[0], "kinjected");
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ mod feedback;
|
|||||||
mod mic;
|
mod mic;
|
||||||
mod session;
|
mod session;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||||
|
// into the host workspace build too. Kotlin only ever calls it on device.
|
||||||
|
mod wol;
|
||||||
|
|
||||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||||
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//! JNI seam for Wake-on-LAN: parse the stored MAC strings and hand them to the shared core sender
|
||||||
|
//! (`punktfunk_core::wol`). Like [`crate::discovery`], this takes no session handle — a sleeping
|
||||||
|
//! host has no ARP entry, so the broadcast the core sends is what wakes it, and Kotlin calls this
|
||||||
|
//! just before connecting to an offline saved host.
|
||||||
|
|
||||||
|
use jni::objects::{JObject, JString};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean` — send a Wake-on-LAN
|
||||||
|
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
|
||||||
|
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
|
||||||
|
/// Returns true if at least one datagram went out.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
macs_csv: JString<'local>,
|
||||||
|
last_ip: JString<'local>,
|
||||||
|
) -> jni::sys::jboolean {
|
||||||
|
let macs_csv: String = match env.get_string(&macs_csv) {
|
||||||
|
Ok(s) => s.into(),
|
||||||
|
Err(_) => return 0,
|
||||||
|
};
|
||||||
|
let last_ip: String = env
|
||||||
|
.get_string(&last_ip)
|
||||||
|
.map(Into::<String>::into)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let macs: Vec<[u8; 6]> = macs_csv
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
|
||||||
|
.collect();
|
||||||
|
if macs.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
|
||||||
|
match punktfunk_core::wol::send_magic_packet(&macs, ip) {
|
||||||
|
Ok(()) => 1,
|
||||||
|
Err(_) => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,5 +11,22 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
||||||
|
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
||||||
|
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
||||||
|
be requested from and approved by Apple for the App ID, then enabled in the provisioning
|
||||||
|
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
||||||
|
|
||||||
|
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
||||||
|
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
|
||||||
|
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
|
||||||
|
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
|
||||||
|
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
|
||||||
|
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
|
||||||
|
file, no multicast entitlement needed). -->
|
||||||
|
<!--
|
||||||
|
<key>com.apple.developer.networking.multicast</key>
|
||||||
|
<true/>
|
||||||
|
-->
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
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.";
|
||||||
@@ -399,6 +400,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
|
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||||
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.";
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ struct ContentView: View {
|
|||||||
_ host: StoredHost, launchID: String? = nil,
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
allowTofu: Bool, requestAccess: Bool = false
|
allowTofu: Bool, requestAccess: Bool = false
|
||||||
) {
|
) {
|
||||||
|
prepareWake(for: host)
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
@@ -426,6 +427,25 @@ struct ContentView: View {
|
|||||||
requestAccess: requestAccess)
|
requestAccess: requestAccess)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
||||||
|
/// • host currently advertising (awake) → refresh its stored Wake-on-LAN MAC(s) from the live
|
||||||
|
/// advert, so a later wake has an up-to-date target;
|
||||||
|
/// • host NOT advertising (likely asleep/off) and we have MAC(s) → fire a magic packet first.
|
||||||
|
/// The connect that follows already retries/times out long enough for a woken host to come
|
||||||
|
/// up; if it's genuinely off/unreachable the connect fails as before. Best-effort and
|
||||||
|
/// non-blocking (the send runs off the main thread).
|
||||||
|
private func prepareWake(for host: StoredHost) {
|
||||||
|
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||||
|
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||||
|
} else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||||
|
let macs = host.wakeMacs
|
||||||
|
let ip = host.address
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The no-PIN delegated-approval flow: open an identified connect the host parks until the
|
/// 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
|
/// 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
|
/// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
|
||||||
@@ -455,7 +475,9 @@ struct ContentView: View {
|
|||||||
/// inside `connect`.)
|
/// 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,
|
||||||
|
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses)
|
||||||
store.add(host)
|
store.add(host)
|
||||||
if d.allowsTofu {
|
if d.allowsTofu {
|
||||||
connect(host, allowTofu: true)
|
connect(host, allowTofu: true)
|
||||||
|
|||||||
@@ -154,7 +154,14 @@ struct HomeView: View {
|
|||||||
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
||||||
onForget: { store.forgetIdentity(host) },
|
onForget: { store.forgetIdentity(host) },
|
||||||
onRemove: { store.remove(host) },
|
onRemove: { store.remove(host) },
|
||||||
onBrowseLibrary: onBrowseLibrary)
|
onBrowseLibrary: onBrowseLibrary,
|
||||||
|
onWake: {
|
||||||
|
let macs = host.wakeMacs
|
||||||
|
let ip = host.address
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private var discoveredSection: some View {
|
private var discoveredSection: some View {
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ struct HostCardView: View {
|
|||||||
let onRemove: () -> Void
|
let onRemove: () -> Void
|
||||||
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
||||||
var onBrowseLibrary: (() -> Void)? = nil
|
var onBrowseLibrary: (() -> Void)? = nil
|
||||||
|
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
|
||||||
|
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
||||||
|
var onWake: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let m = CardMetrics.current
|
let m = CardMetrics.current
|
||||||
@@ -138,6 +141,9 @@ struct HostCardView: View {
|
|||||||
if let onBrowseLibrary {
|
if let onBrowseLibrary {
|
||||||
Button("Browse Library…", action: onBrowseLibrary)
|
Button("Browse Library…", action: onBrowseLibrary)
|
||||||
}
|
}
|
||||||
|
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
|
||||||
|
Button("Wake Host", systemImage: "power", action: onWake)
|
||||||
|
}
|
||||||
if host.pinnedSHA256 != nil {
|
if host.pinnedSHA256 != nil {
|
||||||
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
||||||
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
||||||
|
|||||||
@@ -26,9 +26,16 @@ struct StoredHost: Identifiable, Codable, Hashable {
|
|||||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
||||||
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||||
var mgmtPort: UInt16?
|
var mgmtPort: UInt16?
|
||||||
|
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
|
||||||
|
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
|
||||||
|
/// client can send a magic packet to wake the host later (when it's asleep and no longer
|
||||||
|
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
|
||||||
|
var macAddresses: [String]?
|
||||||
|
|
||||||
var displayName: String { name.isEmpty ? address : name }
|
var displayName: String { name.isEmpty ? address : name }
|
||||||
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||||
|
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||||
|
var wakeMacs: [String] { macAddresses ?? [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StoredHost {
|
extension StoredHost {
|
||||||
@@ -101,6 +108,16 @@ final class HostStore: ObservableObject {
|
|||||||
hosts[i].pinnedSHA256 = fingerprint
|
hosts[i].pinnedSHA256 = fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Learn/refresh this host's Wake-on-LAN MAC(s) from its live advert (called while the host is
|
||||||
|
/// awake, so the client can wake it once it sleeps). No-op when unchanged, so it doesn't churn
|
||||||
|
/// UserDefaults on every discovery tick.
|
||||||
|
func updateMacs(_ hostID: UUID, macs: [String]) {
|
||||||
|
guard !macs.isEmpty,
|
||||||
|
let i = hosts.firstIndex(where: { $0.id == hostID }),
|
||||||
|
hosts[i].macAddresses != macs else { return }
|
||||||
|
hosts[i].macAddresses = macs
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the pinned identity (e.g. after a legitimate host reinstall). This does NOT downgrade
|
/// Drop the pinned identity (e.g. after a legitimate host reinstall). This does NOT downgrade
|
||||||
/// to TOFU: the next connect re-pairs via the PIN ceremony, unless the host advertises
|
/// to TOFU: the next connect re-pairs via the PIN ceremony, unless the host advertises
|
||||||
/// `pair=optional` (the only case the connect path still offers the trust prompt).
|
/// `pair=optional` (the only case the connect path still offers the trust prompt).
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
|||||||
/// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional:
|
/// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional:
|
||||||
/// pairing is mandatory unless this is true (the policy authority is the host's advert).
|
/// pairing is mandatory unless this is true (the policy authority is the host's advert).
|
||||||
public let allowsTofu: Bool
|
public let allowsTofu: Bool
|
||||||
|
/// Wake-on-LAN MAC address(es) the host advertised (mDNS `mac` TXT, comma-separated
|
||||||
|
/// `aa:bb:cc:dd:ee:ff`, routed NIC first). Empty when not advertised. A client persists these
|
||||||
|
/// onto the saved host so it can wake it after it sleeps; advisory/unauthenticated (a wrong
|
||||||
|
/// value only makes a wake fail — the magic packet is inert and the fingerprint still gates
|
||||||
|
/// the connection).
|
||||||
|
public let macAddresses: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -111,10 +117,15 @@ public final class HostDiscovery: ObservableObject {
|
|||||||
var fp: String?
|
var fp: String?
|
||||||
var pair: String?
|
var pair: String?
|
||||||
var id: String?
|
var id: String?
|
||||||
|
var macs: [String] = []
|
||||||
if case let .bonjour(txt) = result.metadata {
|
if case let .bonjour(txt) = result.metadata {
|
||||||
fp = Self.entry(txt, "fp")
|
fp = Self.entry(txt, "fp")
|
||||||
pair = Self.entry(txt, "pair")
|
pair = Self.entry(txt, "pair")
|
||||||
id = Self.entry(txt, "id")
|
id = Self.entry(txt, "id")
|
||||||
|
macs = (Self.entry(txt, "mac") ?? "")
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
}
|
}
|
||||||
let conn = NWConnection(to: result.endpoint, using: .udp)
|
let conn = NWConnection(to: result.endpoint, using: .udp)
|
||||||
connections[key] = conn
|
connections[key] = conn
|
||||||
@@ -129,7 +140,7 @@ public final class HostDiscovery: ObservableObject {
|
|||||||
id: (id?.isEmpty == false) ? id! : name,
|
id: (id?.isEmpty == false) ? id! : name,
|
||||||
name: name, host: address, port: port.rawValue,
|
name: name, host: address, port: port.rawValue,
|
||||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||||
allowsTofu: pair == "optional")
|
allowsTofu: pair == "optional", macAddresses: macs)
|
||||||
self.publish()
|
self.publish()
|
||||||
}
|
}
|
||||||
conn.cancel()
|
conn.cancel()
|
||||||
|
|||||||
@@ -67,6 +67,53 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
|
|||||||
return s.withCString { body($0) }
|
return s.withCString { body($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension PunktfunkConnection {
|
||||||
|
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
||||||
|
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
|
||||||
|
/// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's
|
||||||
|
/// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is
|
||||||
|
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action.
|
||||||
|
/// The MAC-learning path stays active on every platform, so flipping this on once the
|
||||||
|
/// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to
|
||||||
|
/// `true` for iOS/tvOS too (and uncomment the entitlement).
|
||||||
|
static var wakeOnLANAvailable: Bool {
|
||||||
|
#if os(macOS)
|
||||||
|
return true
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
||||||
|
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
||||||
|
/// skipped. `lastKnownIP`, when set, is additionally unicast. The core broadcasts to every
|
||||||
|
/// interface's subnet-directed broadcast + 255.255.255.255 on ports 9/7, repeated.
|
||||||
|
///
|
||||||
|
/// Returns true if at least one datagram went out. Does blocking sends — call OFF the main
|
||||||
|
/// thread. On iOS/tvOS this requires the `com.apple.developer.networking.multicast` entitlement
|
||||||
|
/// (broadcast is otherwise blocked by the OS); macOS needs only the existing network entitlements.
|
||||||
|
@discardableResult
|
||||||
|
static func wakeOnLAN(macs: [String], lastKnownIP: String? = nil) -> Bool {
|
||||||
|
var bytes: [UInt8] = []
|
||||||
|
var count = 0
|
||||||
|
for mac in macs {
|
||||||
|
let parts = mac.split(separator: ":")
|
||||||
|
guard parts.count == 6 else { continue }
|
||||||
|
let octets = parts.compactMap { UInt8($0, radix: 16) }
|
||||||
|
guard octets.count == 6 else { continue }
|
||||||
|
bytes.append(contentsOf: octets)
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
guard count > 0 else { return false }
|
||||||
|
let rc: Int32 = bytes.withUnsafeBufferPointer { buf in
|
||||||
|
withOptionalCString(lastKnownIP) { ip in
|
||||||
|
punktfunk_wake_on_lan(buf.baseAddress, UInt(count), ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rc == statusOK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public final class PunktfunkConnection {
|
public final class PunktfunkConnection {
|
||||||
private var handle: OpaquePointer?
|
private var handle: OpaquePointer?
|
||||||
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
||||||
|
|||||||
@@ -489,6 +489,40 @@ class Plugin:
|
|||||||
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
||||||
return {"ok": False, "error": reason}
|
return {"ok": False, "error": reason}
|
||||||
|
|
||||||
|
async def wake(self, host: str, port: int = 9777) -> dict:
|
||||||
|
"""Send a Wake-on-LAN magic packet to a saved host via the flatpak client's headless
|
||||||
|
``--wake`` mode, so a sleeping host is up by the time the stream ``--connect`` runs.
|
||||||
|
|
||||||
|
The MAC comes from the flatpak client's OWN known-hosts store (learned from the host's
|
||||||
|
mDNS ``mac`` TXT while it was online) — no MAC handling here — so this is a no-op if none
|
||||||
|
has been learned yet. Fire it just before launching a stream; it's fast and best-effort.
|
||||||
|
Returns ``{ok, error?}`` (``ok: False`` when no MAC is known / flatpak missing).
|
||||||
|
"""
|
||||||
|
flatpak = _flatpak()
|
||||||
|
if not flatpak:
|
||||||
|
return {"ok": False, "error": "flatpak-not-found"}
|
||||||
|
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--wake", f"{host}:{port}"]
|
||||||
|
decky.logger.info("wake: %s:%s", host, port)
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*argv,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=_flatpak_env(),
|
||||||
|
)
|
||||||
|
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"ok": False, "error": "wake timed out"}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
decky.logger.exception("wake failed to launch")
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return {"ok": True}
|
||||||
|
reason = (stderr.decode(errors="replace").strip().splitlines() or
|
||||||
|
["no MAC known for this host yet"])[-1]
|
||||||
|
decky.logger.info("wake skipped (rc=%s): %s", proc.returncode, reason)
|
||||||
|
return {"ok": False, "error": reason}
|
||||||
|
|
||||||
async def library(self, host: str, mgmt_port: int = 0, fp: str = "") -> dict:
|
async def library(self, host: str, mgmt_port: int = 0, fp: str = "") -> dict:
|
||||||
"""Fetch a paired host's game library via the flatpak client's headless
|
"""Fetch a paired host's game library via the flatpak client's headless
|
||||||
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
|
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>
|
|||||||
"set_settings",
|
"set_settings",
|
||||||
);
|
);
|
||||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||||
|
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
|
||||||
|
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
|
||||||
|
// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching.
|
||||||
|
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
|
||||||
|
"wake",
|
||||||
|
);
|
||||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||||
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
||||||
export const updateClient = callable<
|
export const updateClient = callable<
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||||
import { toaster } from "@decky/api";
|
import { toaster } from "@decky/api";
|
||||||
import { Navigation } from "@decky/ui";
|
import { Navigation } from "@decky/ui";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
checkUpdate,
|
checkUpdate,
|
||||||
discover,
|
discover,
|
||||||
@@ -220,6 +220,14 @@ export interface PinsApi {
|
|||||||
|
|
||||||
export function usePins(): PinsApi {
|
export function usePins(): PinsApi {
|
||||||
const [pins, setPins] = useState<PinnedGame[]>([]);
|
const [pins, setPins] = useState<PinnedGame[]>([]);
|
||||||
|
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
|
||||||
|
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
|
||||||
|
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
|
||||||
|
// game in the same session would compute from the stale `[]` and clobber the first (silent
|
||||||
|
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
|
||||||
|
// callbacks keep a stable identity (deps free of `pins`).
|
||||||
|
const pinsRef = useRef<PinnedGame[]>([]);
|
||||||
|
pinsRef.current = pins;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +244,7 @@ export function usePins(): PinsApi {
|
|||||||
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
||||||
const save = useCallback(
|
const save = useCallback(
|
||||||
(next: PinnedGame[]) => {
|
(next: PinnedGame[]) => {
|
||||||
|
pinsRef.current = next;
|
||||||
setPins(next);
|
setPins(next);
|
||||||
setPinsBackend(next).catch(() => void refresh());
|
setPinsBackend(next).catch(() => void refresh());
|
||||||
},
|
},
|
||||||
@@ -258,18 +267,20 @@ export function usePins(): PinsApi {
|
|||||||
paired: h.paired,
|
paired: h.paired,
|
||||||
};
|
};
|
||||||
save([
|
save([
|
||||||
...pins.filter((p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id)),
|
...pinsRef.current.filter(
|
||||||
|
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
|
||||||
|
),
|
||||||
pin,
|
pin,
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
[pins, save],
|
[save],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removePin = useCallback(
|
const removePin = useCallback(
|
||||||
(hostFp: string, gameId: string) => {
|
(hostFp: string, gameId: string) => {
|
||||||
save(pins.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
|
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
|
||||||
},
|
},
|
||||||
[pins, save],
|
[save],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPinned = useCallback(
|
const isPinned = useCallback(
|
||||||
@@ -284,14 +295,14 @@ export function usePins(): PinsApi {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
save(
|
save(
|
||||||
pins.map((p) =>
|
pinsRef.current.map((p) =>
|
||||||
p.host_fp === pin.host_fp && p.game_id === pin.game_id
|
p.host_fp === pin.host_fp && p.game_id === pin.game_id
|
||||||
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
|
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[pins, save],
|
[save],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ export const GamePickerModal: FC<{
|
|||||||
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
|
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
|
||||||
const [result, setResult] = useState<LibraryResult | null>(null);
|
const [result, setResult] = useState<LibraryResult | null>(null);
|
||||||
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
|
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
|
||||||
|
// The modal is a detached `showModal` portal that never re-renders from the page's pin
|
||||||
|
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
|
||||||
|
// never flip within a session. Track this host's pinned ids locally, seeded once from the
|
||||||
|
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
|
||||||
|
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
|
||||||
|
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
|
||||||
|
);
|
||||||
|
const togglePin = (g: GameEntry) => {
|
||||||
|
const wasPinned = pinnedIds.has(g.id);
|
||||||
|
setPinnedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (wasPinned) next.delete(g.id);
|
||||||
|
else next.add(g.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (wasPinned) pins.removePin(host.fp, g.id);
|
||||||
|
else pins.addPin(host, g);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let stale = false;
|
let stale = false;
|
||||||
@@ -188,7 +206,7 @@ export const GamePickerModal: FC<{
|
|||||||
{sorted.length > 0 && (
|
{sorted.length > 0 && (
|
||||||
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
|
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
|
||||||
{sorted.map((g: GameEntry) => {
|
{sorted.map((g: GameEntry) => {
|
||||||
const pinned = pins.isPinned(host.fp, g.id);
|
const pinned = pinnedIds.has(g.id);
|
||||||
const safe = isSafeLaunchId(g.id);
|
const safe = isSafeLaunchId(g.id);
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
@@ -199,13 +217,7 @@ export const GamePickerModal: FC<{
|
|||||||
}
|
}
|
||||||
childrenContainerWidth="max"
|
childrenContainerWidth="max"
|
||||||
>
|
>
|
||||||
<DialogButton
|
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||||
style={pickButton}
|
|
||||||
disabled={!safe}
|
|
||||||
onClick={() =>
|
|
||||||
pinned ? pins.removePin(host.fp, g.id) : pins.addPin(host, g)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||||
{pinned ? "Unpin" : "Pin"}
|
{pinned ? "Unpin" : "Pin"}
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
|
|||||||
@@ -151,8 +151,11 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
|||||||
>
|
>
|
||||||
<FaInfoCircle />
|
<FaInfoCircle />
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
<DialogButton style={iconButton} onClick={onGames}>
|
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
|
||||||
<FaThLarge />
|
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
|
||||||
|
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}>
|
||||||
|
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||||
|
Games
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
{needsPair && (
|
{needsPair && (
|
||||||
<DialogButton
|
<DialogButton
|
||||||
@@ -162,7 +165,16 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
|||||||
Pair
|
Pair
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
)}
|
)}
|
||||||
<DialogButton style={actionButton} onClick={() => startStream(host)}>
|
<DialogButton
|
||||||
|
style={actionButton}
|
||||||
|
onClick={() =>
|
||||||
|
needsPair
|
||||||
|
? showModal(
|
||||||
|
<PairModal host={host} onPaired={() => startStream(host)} />,
|
||||||
|
)
|
||||||
|
: startStream(host)
|
||||||
|
}
|
||||||
|
>
|
||||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||||
Stream
|
Stream
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// and start it with RunGame. The wrapper then execs
|
// and start it with RunGame. The wrapper then execs
|
||||||
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||||
|
|
||||||
import { runnerInfo, shortcutArt } from "./backend";
|
import { runnerInfo, shortcutArt, wake } from "./backend";
|
||||||
|
|
||||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||||
@@ -219,6 +219,11 @@ export async function launchStream(
|
|||||||
port: number,
|
port: number,
|
||||||
opts: LaunchOpts = {},
|
opts: LaunchOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
||||||
|
// so it races with the shortcut setup (near-zero added latency), and awaited just before RunGame.
|
||||||
|
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||||
|
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||||
|
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||||
const { appId, runner } = await ensureShortcut();
|
const { appId, runner } = await ensureShortcut();
|
||||||
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
|
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
|
||||||
// disables Steam Input manually — see the Settings instruction).
|
// disables Steam Input manually — see the Settings instruction).
|
||||||
@@ -240,6 +245,7 @@ export async function launchStream(
|
|||||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||||
// script rides behind it as an argument and reads PF_* from the environment.
|
// script rides behind it as an argument and reads PF_* from the environment.
|
||||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||||
|
await waking; // ensure the magic packet is out before the connect attempt
|
||||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ pipewire = "0.9"
|
|||||||
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
|
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
|
||||||
# need the hidapi driver).
|
# need the hidapi driver).
|
||||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||||
|
# The VAAPI GL presenter (video_gl.rs): EGL dmabuf import into a GDK-shared context, dlopened
|
||||||
|
# at runtime (`dynamic`) so GPU-less boxes and the software path never touch libEGL.
|
||||||
|
khronos-egl = { version = "6", features = ["dynamic"] }
|
||||||
|
|
||||||
mdns-sd = "0.20"
|
mdns-sd = "0.20"
|
||||||
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ const CSS: &str = "
|
|||||||
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
|
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
|
||||||
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
|
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
|
||||||
.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); }
|
.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); }
|
||||||
|
.pf-pill.pf-neutral { color: alpha(currentColor, 0.75); background: alpha(currentColor, 0.12); }
|
||||||
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
|
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
|
||||||
background: alpha(currentColor, 0.35); }
|
background: alpha(currentColor, 0.35); }
|
||||||
.pf-pip.pf-online { background: @success_color; }
|
.pf-pip.pf-online { background: @success_color; }
|
||||||
.pf-recent { box-shadow: inset 3px 0 0 0 @accent_bg_color; }
|
/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's
|
||||||
|
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
|
||||||
|
the card's own elevation shadow intact. */
|
||||||
|
.pf-recent { outline: 2px solid @accent_color; outline-offset: -2px; }
|
||||||
.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); }
|
.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); }
|
||||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
||||||
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||||
@@ -112,6 +116,23 @@ pub fn run() -> glib::ExitCode {
|
|||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
||||||
|
// physical pad Steam Input has virtualized — SDL then hides the real device so games
|
||||||
|
// only see the virtual X360 pad. Right for games, wrong for us: capturing the Deck's
|
||||||
|
// built-in controller (trackpads/paddles/gyro, 28DE:1205) needs SDL's HIDAPI driver
|
||||||
|
// to enumerate the REAL device, and the built-in pad can never leave Steam Input
|
||||||
|
// ("Steam Controller" is always-required), so this filter is the only off switch we
|
||||||
|
// get. Clear it while still single-threaded (the gamepad worker starts with the UI);
|
||||||
|
// we dedupe the virtual pad ourselves (`gamepad.rs` `active_id` skips steam_virtual).
|
||||||
|
for var in [
|
||||||
|
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||||
|
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||||
|
] {
|
||||||
|
if let Ok(v) = std::env::var(var) {
|
||||||
|
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||||
|
std::env::remove_var(var);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
||||||
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
||||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||||
@@ -121,6 +142,11 @@ pub fn run() -> glib::ExitCode {
|
|||||||
if let Some(target) = crate::cli::arg_value("--library") {
|
if let Some(target) = crate::cli::arg_value("--library") {
|
||||||
return crate::cli::headless_library(&target);
|
return crate::cli::headless_library(&target);
|
||||||
}
|
}
|
||||||
|
// Headless Wake-on-LAN (no GTK window): `--wake host[:port]`. The Decky wrapper calls this
|
||||||
|
// before the stream launch so a sleeping host is up by the time `--connect` runs.
|
||||||
|
if crate::cli::arg_value("--wake").is_some() {
|
||||||
|
return crate::cli::cli_wake();
|
||||||
|
}
|
||||||
let mut builder = adw::Application::builder().application_id(APP_ID);
|
let mut builder = adw::Application::builder().application_id(APP_ID);
|
||||||
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each
|
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each
|
||||||
// launch its own primary instance instead of forwarding to a still-registered name.
|
// launch its own primary instance instead of forwarding to a still-registered name.
|
||||||
|
|||||||
@@ -94,16 +94,61 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
|
|||||||
}
|
}
|
||||||
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
||||||
let (addr, port) = parse_host_port(&target);
|
let (addr, port) = parse_host_port(&target);
|
||||||
|
// An unparsable port (`host:notaport`) used to make the whole request `None` → the app
|
||||||
|
// silently landed on the hosts page with no session and no message. Fall back to the
|
||||||
|
// native default like the add-host dialog, and say so, instead of doing nothing.
|
||||||
|
let port = port.unwrap_or_else(|| {
|
||||||
|
eprintln!("--connect: unparsable port in '{target}', using default 9777");
|
||||||
|
9777
|
||||||
|
});
|
||||||
|
// Pull the wake MAC(s) from the store (learned from the host's mDNS `mac` TXT while it was
|
||||||
|
// online) so a `--connect` to a known host can still be woken if we add that later.
|
||||||
|
let mac = crate::trust::KnownHosts::load()
|
||||||
|
.hosts
|
||||||
|
.iter()
|
||||||
|
.find(|h| h.addr == addr && h.port == port)
|
||||||
|
.map(|h| h.mac.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
Some(ConnectRequest {
|
Some(ConnectRequest {
|
||||||
name: addr.clone(),
|
name: addr.clone(),
|
||||||
addr,
|
addr,
|
||||||
port: port?,
|
port,
|
||||||
fp_hex: None,
|
fp_hex: None,
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
||||||
|
mac,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, without
|
||||||
|
/// opening a window. The Decky wrapper calls this before launching the stream so a sleeping host
|
||||||
|
/// is up by the time `--connect` runs. The MAC comes from the known-hosts store (learned from the
|
||||||
|
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
||||||
|
pub fn cli_wake() -> glib::ExitCode {
|
||||||
|
let Some(target) = arg_value("--wake") else {
|
||||||
|
eprintln!("--wake requires host[:port]");
|
||||||
|
return glib::ExitCode::FAILURE;
|
||||||
|
};
|
||||||
|
let (addr, port) = parse_host_port(&target);
|
||||||
|
let port = port.unwrap_or(9777);
|
||||||
|
let mac = crate::trust::KnownHosts::load()
|
||||||
|
.hosts
|
||||||
|
.iter()
|
||||||
|
.find(|h| h.addr == addr && h.port == port)
|
||||||
|
.map(|h| h.mac.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if mac.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \
|
||||||
|
advertised MAC is learned"
|
||||||
|
);
|
||||||
|
return glib::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
crate::wol::wake(&mac, addr.parse().ok());
|
||||||
|
println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len());
|
||||||
|
glib::ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
/// `--browse host[:port]` — open the gamepad library launcher for that host instead of
|
/// `--browse host[:port]` — open the gamepad library launcher for that host instead of
|
||||||
/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must
|
/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must
|
||||||
/// already be paired: the stored pin is what lets the launcher fetch the library and
|
/// already be paired: the stored pin is what lets the launcher fetch the library and
|
||||||
@@ -131,6 +176,7 @@ pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> {
|
|||||||
fp_hex: k.map(|k| k.fp_hex.clone()),
|
fp_hex: k.map(|k| k.fp_hex.clone()),
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: k.map(|k| k.mac.clone()).unwrap_or_default(),
|
||||||
},
|
},
|
||||||
k.is_some_and(|k| k.paired),
|
k.is_some_and(|k| k.paired),
|
||||||
mgmt,
|
mgmt,
|
||||||
@@ -203,6 +249,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
|||||||
),
|
),
|
||||||
pair_optional: true,
|
pair_optional: true,
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: Vec::new(),
|
||||||
};
|
};
|
||||||
let mock_advert =
|
let mock_advert =
|
||||||
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
||||||
@@ -214,6 +261,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
|||||||
fp_hex: fp.to_string(),
|
fp_hex: fp.to_string(),
|
||||||
pair: "required".to_string(),
|
pair: "required".to_string(),
|
||||||
mgmt_port: None,
|
mgmt_port: None,
|
||||||
|
mac: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// What the self-capture renders: the main window, except for scenes that open their
|
// What the self-capture renders: the main window, except for scenes that open their
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ pub struct DiscoveredHost {
|
|||||||
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
|
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
|
||||||
/// library client then falls back to the well-known default.
|
/// library client then falls back to the well-known default.
|
||||||
pub mgmt_port: Option<u16>,
|
pub mgmt_port: Option<u16>,
|
||||||
|
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the
|
||||||
|
/// hosts page persists onto the matching saved host so it can wake it later. Empty if absent.
|
||||||
|
pub mac: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One discovery update for the UI's advert map.
|
/// One discovery update for the UI's advert map.
|
||||||
@@ -81,6 +84,11 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
|||||||
fp_hex: val("fp"),
|
fp_hex: val("fp"),
|
||||||
pair: val("pair"),
|
pair: val("pair"),
|
||||||
mgmt_port: val("mgmt").parse().ok(),
|
mgmt_port: val("mgmt").parse().ok(),
|
||||||
|
mac: val("mac")
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||||
|
|||||||
+112
-11
@@ -551,6 +551,14 @@ struct Worker<'a> {
|
|||||||
/// switch / detach so a contact held at that moment doesn't stick. surface 0 = the legacy single
|
/// 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.
|
/// touchpad, 1/2 = a Steam left/right pad.
|
||||||
held_touches: std::collections::HashSet<(u8, u8)>,
|
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||||
|
/// Per Steam-pad surface (index 0 = left/surface 1, 1 = right/surface 2): the last wire
|
||||||
|
/// coordinates + whether a finger is on it. Pad CLICKS arrive as buttons with no position,
|
||||||
|
/// so the click forward reuses the surface's live contact point.
|
||||||
|
surface_last: [(i16, i16, bool); 2],
|
||||||
|
/// Steam-pad clicks currently held (surface−1 indexed): keeps the click bit asserted
|
||||||
|
/// through touch-motion frames (which would otherwise clear it host-side) and lets the
|
||||||
|
/// flush lift a click held across detach/pad-switch.
|
||||||
|
held_clicks: [bool; 2],
|
||||||
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<()>,
|
||||||
@@ -681,6 +689,24 @@ impl Worker<'_> {
|
|||||||
}
|
}
|
||||||
*v = i32::MIN;
|
*v = i32::MIN;
|
||||||
}
|
}
|
||||||
|
// Lift any Steam-pad click held at this moment — a click that survives a
|
||||||
|
// detach/pad-switch would leave the host's pad pressed forever.
|
||||||
|
for i in 0..2usize {
|
||||||
|
if std::mem::take(&mut self.held_clicks[i]) {
|
||||||
|
let (x, y, _) = self.surface_last[i];
|
||||||
|
let _ = c.send_rich_input(RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface: (i as u8) + 1,
|
||||||
|
finger: 0,
|
||||||
|
touch: false,
|
||||||
|
click: false,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
pressure: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.surface_last = [(0, 0, false); 2];
|
||||||
// Lift any touchpad contact the host still believes is down (surface 0 = legacy pad).
|
// Lift any touchpad contact the host still believes is down (surface 0 = legacy pad).
|
||||||
for (surface, finger) in self.held_touches.drain() {
|
for (surface, finger) in self.held_touches.drain() {
|
||||||
let rich = if surface == 0 {
|
let rich = if surface == 0 {
|
||||||
@@ -709,6 +735,8 @@ impl Worker<'_> {
|
|||||||
self.held_buttons.clear();
|
self.held_buttons.clear();
|
||||||
self.last_axis = [i32::MIN; 6];
|
self.last_axis = [i32::MIN; 6];
|
||||||
self.held_touches.clear();
|
self.held_touches.clear();
|
||||||
|
self.held_clicks = [false; 2];
|
||||||
|
self.surface_last = [(0, 0, false); 2];
|
||||||
}
|
}
|
||||||
// A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too.
|
// A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too.
|
||||||
self.reset_chord();
|
self.reset_chord();
|
||||||
@@ -789,26 +817,29 @@ impl Worker<'_> {
|
|||||||
y: f32,
|
y: f32,
|
||||||
active: bool,
|
active: bool,
|
||||||
) {
|
) {
|
||||||
let Some(c) = self.attached.as_ref() else {
|
let Some(c) = self.attached.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let multi = self
|
let multi = self.is_multi_touchpad(which);
|
||||||
.open
|
|
||||||
.as_ref()
|
|
||||||
.filter(|(id, _)| *id == 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 (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||||
let rich = if multi {
|
let rich = if multi {
|
||||||
|
let (wx, wy) = (
|
||||||
|
(cx * 65535.0 - 32768.0) as i16,
|
||||||
|
(cy * 65535.0 - 32768.0) as i16,
|
||||||
|
);
|
||||||
|
let i = (surface - 1).min(1) as usize;
|
||||||
|
self.surface_last[i] = (wx, wy, active);
|
||||||
RichInput::TouchpadEx {
|
RichInput::TouchpadEx {
|
||||||
pad: 0,
|
pad: 0,
|
||||||
surface,
|
surface,
|
||||||
finger,
|
finger,
|
||||||
touch: active,
|
touch: active,
|
||||||
click: false,
|
// The pad's physical click is a separate BUTTON event (see forward_click) —
|
||||||
x: (cx * 65535.0 - 32768.0) as i16,
|
// carry the held state so a motion frame can't clear a click mid-press.
|
||||||
y: (cy * 65535.0 - 32768.0) as i16,
|
click: self.held_clicks[i],
|
||||||
|
x: wx,
|
||||||
|
y: wy,
|
||||||
pressure: 0,
|
pressure: 0,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -828,6 +859,57 @@ impl Worker<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The open pad has two touchpads (Steam Deck / Steam Controller) — the gate for the
|
||||||
|
/// `TouchpadEx` surface encoding and the pad-click button re-route.
|
||||||
|
fn is_multi_touchpad(&self, which: u32) -> bool {
|
||||||
|
self.open
|
||||||
|
.as_ref()
|
||||||
|
.filter(|(id, _)| *id == which)
|
||||||
|
.map(|(_, p)| p.touchpads_count() >= 2)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SDL's Steam Deck mapping delivers the pad CLICKS as gamepad buttons — the generic
|
||||||
|
/// `touchpad` button is the LEFT pad's click and `misc2` the RIGHT's (SDL_gamepad_db.h
|
||||||
|
/// `touchpad:b17,misc2:b16`). They must NOT ride the button plane: it has no surface
|
||||||
|
/// identity, and the host maps `BTN_TOUCHPAD` to the RIGHT pad (DualSense convention) —
|
||||||
|
/// which is exactly "a left-pad click registers on the right pad". Only for the open
|
||||||
|
/// multi-touchpad pad; a DualSense's single `touchpad` button stays a wire button.
|
||||||
|
fn steam_click_surface(&self, which: u32, button: sdl3::gamepad::Button) -> Option<u8> {
|
||||||
|
use sdl3::gamepad::Button;
|
||||||
|
if !self.is_multi_touchpad(which) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
Button::Touchpad => Some(1),
|
||||||
|
Button::Misc2 => Some(2),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward a Steam-pad click on the rich plane, bound to its surface. Click events carry
|
||||||
|
/// no position, so reuse the surface's live contact point; a physical click implies
|
||||||
|
/// contact, so `touch` stays asserted while the click is down even if the touch event
|
||||||
|
/// hasn't arrived yet (event-order safety).
|
||||||
|
fn forward_click(&mut self, surface: u8, down: bool) {
|
||||||
|
let Some(c) = self.attached.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let i = (surface - 1).min(1) as usize;
|
||||||
|
self.held_clicks[i] = down;
|
||||||
|
let (x, y, touching) = self.surface_last[i];
|
||||||
|
let _ = c.send_rich_input(RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface,
|
||||||
|
finger: 0,
|
||||||
|
touch: touching || down,
|
||||||
|
click: down,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
pressure: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Publish the pad list, active pad, and pin to the UI-facing mutexes.
|
/// Publish the pad list, active pad, and pin to the UI-facing mutexes.
|
||||||
fn publish(&self) {
|
fn publish(&self) {
|
||||||
let mut list: Vec<PadInfo> = self
|
let mut list: Vec<PadInfo> = self
|
||||||
@@ -910,7 +992,16 @@ impl Worker<'_> {
|
|||||||
if !self.order.contains(&which) {
|
if !self.order.contains(&which) {
|
||||||
self.order.push(which);
|
self.order.push(which);
|
||||||
if let Some(p) = self.pad_info(which) {
|
if let Some(p) = self.pad_info(which) {
|
||||||
tracing::info!(name = p.name, "gamepad attached");
|
// Full identity: on a Steam Deck this is the one lever for diagnosing an
|
||||||
|
// empty controller list — it tells you whether SDL sees the physical pad
|
||||||
|
// (28DE:1205), Steam Input's virtual pad (28DE:11FF), both, or nothing.
|
||||||
|
tracing::info!(
|
||||||
|
name = p.name,
|
||||||
|
key = p.key,
|
||||||
|
pref = ?p.pref,
|
||||||
|
steam_virtual = p.steam_virtual,
|
||||||
|
"gamepad attached"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
self.refresh_active(active);
|
self.refresh_active(active);
|
||||||
}
|
}
|
||||||
@@ -926,6 +1017,10 @@ impl Worker<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
|
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
|
||||||
|
if let Some(surface) = self.steam_click_surface(which, button) {
|
||||||
|
self.forward_click(surface, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(c) = self.attached.clone() else {
|
let Some(c) = self.attached.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -936,6 +1031,10 @@ impl Worker<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ControllerButtonUp { which, button, .. } if active == Some(which) => {
|
Event::ControllerButtonUp { which, button, .. } if active == Some(which) => {
|
||||||
|
if let Some(surface) = self.steam_click_surface(which, button) {
|
||||||
|
self.forward_click(surface, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(c) = self.attached.clone() else {
|
let Some(c) = self.attached.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1149,6 +1248,8 @@ fn run(
|
|||||||
last_axis: [i32::MIN; 6],
|
last_axis: [i32::MIN; 6],
|
||||||
held_buttons: Vec::new(),
|
held_buttons: Vec::new(),
|
||||||
held_touches: std::collections::HashSet::new(),
|
held_touches: std::collections::HashSet::new(),
|
||||||
|
surface_last: [(0, 0, false); 2],
|
||||||
|
held_clicks: [false; 2],
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
escape_tx: escape_tx.clone(),
|
escape_tx: escape_tx.clone(),
|
||||||
disconnect_tx: disconnect_tx.clone(),
|
disconnect_tx: disconnect_tx.clone(),
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ pub fn start_session_with(
|
|||||||
}
|
}
|
||||||
let mode = resolve_mode(&app);
|
let mode = resolve_mode(&app);
|
||||||
let s = app.settings.borrow();
|
let s = app.settings.borrow();
|
||||||
|
// The presenter raises this when hardware frames can't be displayed; the session pump
|
||||||
|
// demotes the decoder to software (see `SessionParams::force_software`).
|
||||||
|
let force_software = Arc::new(AtomicBool::new(false));
|
||||||
let params = SessionParams {
|
let params = SessionParams {
|
||||||
host: req.addr.clone(),
|
host: req.addr.clone(),
|
||||||
port: req.port,
|
port: req.port,
|
||||||
@@ -125,6 +128,7 @@ pub fn start_session_with(
|
|||||||
pin,
|
pin,
|
||||||
identity: app.identity.clone(),
|
identity: app.identity.clone(),
|
||||||
connect_timeout: opts.connect_timeout,
|
connect_timeout: opts.connect_timeout,
|
||||||
|
force_software: force_software.clone(),
|
||||||
};
|
};
|
||||||
let inhibit = s.inhibit_shortcuts;
|
let inhibit = s.inhibit_shortcuts;
|
||||||
let show_stats = s.show_stats;
|
let show_stats = s.show_stats;
|
||||||
@@ -149,6 +153,7 @@ pub fn start_session_with(
|
|||||||
inhibit,
|
inhibit,
|
||||||
show_stats,
|
show_stats,
|
||||||
frames: Some(frames),
|
frames: Some(frames),
|
||||||
|
force_software,
|
||||||
waiting: opts.waiting,
|
waiting: opts.waiting,
|
||||||
page: None,
|
page: None,
|
||||||
};
|
};
|
||||||
@@ -198,6 +203,9 @@ struct SessionUi {
|
|||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
/// Decoded-frame receiver, handed to the stream page once on `Connected`.
|
/// Decoded-frame receiver, handed to the stream page once on `Connected`.
|
||||||
frames: Option<async_channel::Receiver<DecodedFrame>>,
|
frames: Option<async_channel::Receiver<DecodedFrame>>,
|
||||||
|
/// Shared with the session pump — the stream page's presenter raises it to demote
|
||||||
|
/// the decoder to software when hardware frames can't be displayed.
|
||||||
|
force_software: Arc<AtomicBool>,
|
||||||
/// The "waiting for approval" dialog (request-access flow), dismissed on the first event.
|
/// The "waiting for approval" dialog (request-access flow), dismissed on the first event.
|
||||||
waiting: Option<adw::AlertDialog>,
|
waiting: Option<adw::AlertDialog>,
|
||||||
page: Option<crate::ui_stream::StreamPage>,
|
page: Option<crate::ui_stream::StreamPage>,
|
||||||
@@ -259,6 +267,7 @@ impl SessionUi {
|
|||||||
window: self.app.window.clone(),
|
window: self.app.window.clone(),
|
||||||
connector,
|
connector,
|
||||||
frames: self.frames.take().expect("Connected delivered once"),
|
frames: self.frames.take().expect("Connected delivered once"),
|
||||||
|
force_software: self.force_software.clone(),
|
||||||
clock_offset_ns,
|
clock_offset_ns,
|
||||||
escape_rx: self.app.gamepad.escape_events(),
|
escape_rx: self.app.gamepad.escape_events(),
|
||||||
disconnect_rx: self.app.gamepad.disconnect_events(),
|
disconnect_rx: self.app.gamepad.disconnect_events(),
|
||||||
@@ -280,6 +289,39 @@ impl SessionUi {
|
|||||||
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
|
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
|
||||||
self.app.window.fullscreen();
|
self.app.window.fullscreen();
|
||||||
}
|
}
|
||||||
|
// A Deck streaming without its raw built-in controller is invisible degradation:
|
||||||
|
// SDL sees only Steam's virtual X360 pad, so the right trackpad arrives at the
|
||||||
|
// host as whatever Steam's template synthesizes (a right stick by default) and
|
||||||
|
// the left trackpad, paddles and gyro not at all. The built-in pad can never
|
||||||
|
// leave Steam Input ("Steam Controller" is always-required in the shortcut's
|
||||||
|
// matrix — Disable Steam Input only affects other brands), so raw capture rides
|
||||||
|
// the session-scoped Valve HIDAPI drivers + the cleared SDL device filter (see
|
||||||
|
// `app::run`). The real 28DE:1205 identity enumerates shortly after attach —
|
||||||
|
// check once that settles and say so, instead of streaming silently degraded.
|
||||||
|
if crate::gamepad::is_steam_deck() {
|
||||||
|
let app = self.app.clone();
|
||||||
|
let stop = self.stop.clone();
|
||||||
|
glib::timeout_add_seconds_local_once(4, move || {
|
||||||
|
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
return; // session already over
|
||||||
|
}
|
||||||
|
if app.gamepad.active().is_none_or(|pad| pad.steam_virtual) {
|
||||||
|
tracing::warn!(
|
||||||
|
"the Deck's raw built-in controller (28DE:1205) never enumerated \
|
||||||
|
— only Steam's virtual pad is visible, so trackpads, paddles and \
|
||||||
|
gyro can't be captured (sticks + buttons still work). Check the \
|
||||||
|
startup log for SDL_GAMECONTROLLER_IGNORE_DEVICES and the \
|
||||||
|
Settings controller list."
|
||||||
|
);
|
||||||
|
let toast = adw::Toast::new(
|
||||||
|
"Steam is only exposing its virtual gamepad — trackpads, paddles \
|
||||||
|
and gyro won't reach the game (sticks and buttons still work).",
|
||||||
|
);
|
||||||
|
toast.set_timeout(12);
|
||||||
|
app.toasts.add_toast(toast);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
self.page = Some(p);
|
self.page = Some(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ mod ui_stream;
|
|||||||
mod ui_trust;
|
mod ui_trust;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod video;
|
mod video;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod video_gl;
|
||||||
|
|
||||||
|
mod wol;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn main() -> gtk::glib::ExitCode {
|
fn main() -> gtk::glib::ExitCode {
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ pub struct SessionParams {
|
|||||||
/// connection until the operator clicks Approve in its console (so this must exceed the
|
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||||
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||||
pub connect_timeout: Duration,
|
pub connect_timeout: Duration,
|
||||||
|
/// Raised by the PRESENTER when hardware frames can't be displayed (GL converter init
|
||||||
|
/// failed / dmabuf import rejected): the pump demotes the decoder to software and
|
||||||
|
/// re-requests a keyframe. Decode itself succeeds in that state, so nothing else
|
||||||
|
/// would recover — without this the stream stays black.
|
||||||
|
pub force_software: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The session pump's share of the unified stats window (design/stats-unification.md):
|
/// The session pump's share of the unified stats window (design/stats-unification.md):
|
||||||
@@ -238,6 +243,7 @@ fn pump(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let force_software = params.force_software.clone();
|
||||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||||
@@ -331,6 +337,29 @@ fn pump(
|
|||||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||||
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
||||||
}
|
}
|
||||||
|
// The presenter's verdict: hardware frames can't be displayed (GL converter
|
||||||
|
// init failed / dmabuf import rejected) — demote to software here, on the
|
||||||
|
// decoder's own thread. Decode succeeds in that state, so the error-streak
|
||||||
|
// demotion above never fires.
|
||||||
|
if force_software.swap(false, Ordering::Relaxed) {
|
||||||
|
if let Err(e) = decoder.force_software() {
|
||||||
|
break Some(format!("software decoder rebuild: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A decode error / VAAPI→software demotion asks for a fresh IDR: the infinite
|
||||||
|
// GOP has no periodic keyframe, so a rebuilt/erroring decoder would stay
|
||||||
|
// gray/frozen until an unrelated packet drop happened to request one. Route it
|
||||||
|
// through the same throttle as loss recovery below.
|
||||||
|
if decoder.take_keyframe_request() {
|
||||||
|
let now = Instant::now();
|
||||||
|
if last_kf_req
|
||||||
|
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||||
|
{
|
||||||
|
last_kf_req = Some(now);
|
||||||
|
let _ = connector.request_keyframe();
|
||||||
|
tracing::debug!("requested keyframe (decoder recovery)");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {}
|
Err(PunktfunkError::NoFrame) => {}
|
||||||
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ pub struct KnownHost {
|
|||||||
/// most-recent card with the accent bar. `default` so pre-existing stores load.
|
/// most-recent card with the accent bar. `default` so pre-existing stores load.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_used: Option<u64>,
|
pub last_used: Option<u64>,
|
||||||
|
/// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it
|
||||||
|
/// was online, so we can wake it once it sleeps and stops advertising. `default` so
|
||||||
|
/// pre-existing stores load; empty until first learned.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mac: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
@@ -115,6 +120,10 @@ impl KnownHosts {
|
|||||||
if entry.last_used.is_some() {
|
if entry.last_used.is_some() {
|
||||||
h.last_used = entry.last_used;
|
h.last_used = entry.last_used;
|
||||||
}
|
}
|
||||||
|
// Likewise a trust-decision upsert (which carries no MAC) must not wipe learned MACs.
|
||||||
|
if !entry.mac.is_empty() {
|
||||||
|
h.mac = entry.mac;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.hosts.push(entry);
|
self.hosts.push(entry);
|
||||||
}
|
}
|
||||||
@@ -132,10 +141,33 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
|||||||
fp_hex: fp_hex.to_string(),
|
fp_hex: fp_hex.to_string(),
|
||||||
paired,
|
paired,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
|
mac: Vec::new(),
|
||||||
});
|
});
|
||||||
let _ = known.save();
|
let _ = known.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||||
|
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||||
|
/// the hosts page can call it on every discovery tick without churning the store.
|
||||||
|
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||||
|
if mac.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
let Some(h) = known
|
||||||
|
.hosts
|
||||||
|
.iter_mut()
|
||||||
|
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if h.mac == mac {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
h.mac = mac.to_vec();
|
||||||
|
let _ = known.save();
|
||||||
|
}
|
||||||
|
|
||||||
/// Stamp "now" as this host's last successful connect (drives the hosts page's
|
/// Stamp "now" as this host's last successful connect (drives the hosts page's
|
||||||
/// most-recent accent). No-op when the fingerprint isn't stored.
|
/// most-recent accent). No-op when the fingerprint isn't stored.
|
||||||
pub fn touch_last_used(fp_hex: &str) {
|
pub fn touch_last_used(fp_hex: &str) {
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ struct State {
|
|||||||
anim_active: Cell<bool>,
|
anim_active: Cell<bool>,
|
||||||
last_tick: Cell<i64>,
|
last_tick: Cell<i64>,
|
||||||
animations: bool,
|
animations: bool,
|
||||||
|
/// Deck (or any low-power box): shrink the per-frame GPU work so navigation stays smooth
|
||||||
|
/// — fewer laid-out cards (fewer 3D offscreen passes) and a frozen aurora (no 30 Hz
|
||||||
|
/// full-screen CPU upscale + multi-MB texture upload contending for the iGPU's shared
|
||||||
|
/// bandwidth). The Deck iGPU otherwise drops to ~16 fps mid-navigation.
|
||||||
|
low_power: bool,
|
||||||
detail_title: gtk::Label,
|
detail_title: gtk::Label,
|
||||||
detail_store: gtk::Label,
|
detail_store: gtk::Label,
|
||||||
/// Transient error strip on the carousel scene (connect failures land here — the
|
/// Transient error strip on the carousel scene (connect failures land here — the
|
||||||
@@ -300,9 +305,12 @@ fn build(app: Rc<App>, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc<
|
|||||||
content.append(&stack);
|
content.append(&stack);
|
||||||
content.append(&hints);
|
content.append(&hints);
|
||||||
|
|
||||||
|
let low_power = crate::gamepad::is_steam_deck();
|
||||||
let root = gtk::Overlay::new();
|
let root = gtk::Overlay::new();
|
||||||
root.add_css_class("pf-gl-page");
|
root.add_css_class("pf-gl-page");
|
||||||
root.set_child(Some(&build_aurora()));
|
// On the Deck the animated aurora's per-frame CPU upscale + texture upload starves the
|
||||||
|
// coverflow of iGPU bandwidth — freeze it (drift is centimeters/minute, unnoticeable).
|
||||||
|
root.set_child(Some(&build_aurora(low_power)));
|
||||||
root.add_overlay(&content);
|
root.add_overlay(&content);
|
||||||
root.set_focusable(true);
|
root.set_focusable(true);
|
||||||
|
|
||||||
@@ -330,6 +338,7 @@ fn build(app: Rc<App>, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc<
|
|||||||
anim_active: Cell::new(false),
|
anim_active: Cell::new(false),
|
||||||
last_tick: Cell::new(0),
|
last_tick: Cell::new(0),
|
||||||
animations: animations_enabled(),
|
animations: animations_enabled(),
|
||||||
|
low_power,
|
||||||
detail_title,
|
detail_title,
|
||||||
detail_store,
|
detail_store,
|
||||||
status,
|
status,
|
||||||
@@ -917,10 +926,14 @@ fn relayout(state: &State) {
|
|||||||
}
|
}
|
||||||
let pos = state.anim_pos.get();
|
let pos = state.anim_pos.get();
|
||||||
let bump = state.bump.get();
|
let bump = state.bump.get();
|
||||||
|
// Each laid-out side card is a non-affine (perspective + rotate_3d) transform, which GSK
|
||||||
|
// renders through its own offscreen pass — so the visible count is the per-frame GPU cost.
|
||||||
|
// Trim it hard on the Deck; desktop keeps the full deep shelf.
|
||||||
|
let range = if state.low_power { 3.0 } else { VISIBLE_RANGE };
|
||||||
for (i, card) in state.cards.borrow().iter().enumerate() {
|
for (i, card) in state.cards.borrow().iter().enumerate() {
|
||||||
let d = i as f64 - pos;
|
let d = i as f64 - pos;
|
||||||
let a = d.abs();
|
let a = d.abs();
|
||||||
if a > VISIBLE_RANGE {
|
if a > range {
|
||||||
card.root.set_visible(false);
|
card.root.set_visible(false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1046,7 @@ fn animations_enabled() -> bool {
|
|||||||
/// The full-bleed aurora: a DrawingArea re-rendered at ~30 Hz off the frame clock (the
|
/// The full-bleed aurora: a DrawingArea re-rendered at ~30 Hz off the frame clock (the
|
||||||
/// Swift TimelineView cadence — drift is centimeters per minute, display rate would be
|
/// Swift TimelineView cadence — drift is centimeters per minute, display rate would be
|
||||||
/// wasted heat on a couch device).
|
/// wasted heat on a couch device).
|
||||||
fn build_aurora() -> gtk::DrawingArea {
|
fn build_aurora(low_power: bool) -> gtk::DrawingArea {
|
||||||
let area = gtk::DrawingArea::new();
|
let area = gtk::DrawingArea::new();
|
||||||
area.set_hexpand(true);
|
area.set_hexpand(true);
|
||||||
area.set_vexpand(true);
|
area.set_vexpand(true);
|
||||||
@@ -1043,7 +1056,9 @@ fn build_aurora() -> gtk::DrawingArea {
|
|||||||
let t = t.clone();
|
let t = t.clone();
|
||||||
area.set_draw_func(move |_, cr, w, h| draw_aurora(cr, w, h, t.get(), &cache));
|
area.set_draw_func(move |_, cr, w, h| draw_aurora(cr, w, h, t.get(), &cache));
|
||||||
}
|
}
|
||||||
if animations_enabled() {
|
// Deck: render once, frozen — the 30 Hz tick's CPU upscale + texture upload is the
|
||||||
|
// bandwidth cost that starves the coverflow. Desktop keeps the live drift.
|
||||||
|
if animations_enabled() && !low_power {
|
||||||
let start = Cell::new(0i64);
|
let start = Cell::new(0i64);
|
||||||
let last = Cell::new(0i64);
|
let last = Cell::new(0i64);
|
||||||
area.add_tick_callback(move |area, clock| {
|
area.add_tick_callback(move |area, clock| {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ pub struct ConnectRequest {
|
|||||||
/// `("steam:570", "Dota 2")`) — set by the library page's card activation; the id
|
/// `("steam:570", "Dota 2")`) — set by the library page's card activation; the id
|
||||||
/// rides the Hello and the name titles the stream page. `None` = plain desktop session.
|
/// rides the Hello and the name titles the stream page. `None` = plain desktop session.
|
||||||
pub launch: Option<(String, String)>,
|
pub launch: Option<(String, String)>,
|
||||||
|
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert). Used to send a
|
||||||
|
/// magic packet before connecting to an offline host. Empty when none is known.
|
||||||
|
pub mac: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectRequest {
|
impl ConnectRequest {
|
||||||
@@ -153,6 +156,15 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
|
|||||||
let disc_heading = heading("On this network");
|
let disc_heading = heading("On this network");
|
||||||
let disc_flow = make_flow();
|
let disc_flow = make_flow();
|
||||||
|
|
||||||
|
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
|
||||||
|
// the child's own `activate` signal — so bridge it back to the child, where each card wires
|
||||||
|
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
|
||||||
|
for flow in [&saved_flow, &disc_flow] {
|
||||||
|
flow.connect_child_activated(|_, child| {
|
||||||
|
child.activate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Shown under the discovered heading while no (unsaved) advert is live yet.
|
// Shown under the discovered heading while no (unsaved) advert is live yet.
|
||||||
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
let spinner = gtk::Spinner::new();
|
let spinner = gtk::Spinner::new();
|
||||||
@@ -305,6 +317,14 @@ fn rebuild(state: &Rc<State>) {
|
|||||||
state.saved_flow.remove_all();
|
state.saved_flow.remove_all();
|
||||||
for k in &known.hosts {
|
for k in &known.hosts {
|
||||||
let online = adverts.values().any(|a| matches(k, a));
|
let online = adverts.values().any(|a| matches(k, a));
|
||||||
|
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake it
|
||||||
|
// once it sleeps and stops advertising (no-op / no disk write when unchanged).
|
||||||
|
if let Some(a) = adverts
|
||||||
|
.values()
|
||||||
|
.find(|a| matches(k, a) && !a.mac.is_empty())
|
||||||
|
{
|
||||||
|
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||||
|
}
|
||||||
let recent = most_recent.as_deref() == Some(k.fp_hex.as_str());
|
let recent = most_recent.as_deref() == Some(k.fp_hex.as_str());
|
||||||
state
|
state
|
||||||
.saved_flow
|
.saved_flow
|
||||||
@@ -412,6 +432,7 @@ fn saved_card(
|
|||||||
// connect; TOFU eligibility is irrelevant.
|
// connect; TOFU eligibility is irrelevant.
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: k.mac.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Presence pip + spelled-out state, then the trust pill.
|
// Presence pip + spelled-out state, then the trust pill.
|
||||||
@@ -483,11 +504,24 @@ fn saved_card(
|
|||||||
Box::new(move || forget_dialog(&state, &fp, &name)),
|
Box::new(move || forget_dialog(&state, &fp, &name)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// Explicit "just wake it" (the tap-to-connect already auto-wakes an offline host).
|
||||||
|
let mac = k.mac.clone();
|
||||||
|
let addr = k.addr.clone();
|
||||||
|
add(
|
||||||
|
"wake",
|
||||||
|
Box::new(move || crate::wol::wake(&mac, addr.parse().ok())),
|
||||||
|
);
|
||||||
|
}
|
||||||
overlay.insert_action_group("card", Some(&actions));
|
overlay.insert_action_group("card", Some(&actions));
|
||||||
|
|
||||||
let menu = gio::Menu::new();
|
let menu = gio::Menu::new();
|
||||||
menu.append(Some("Pair with PIN…"), Some("card.pair"));
|
menu.append(Some("Pair with PIN…"), Some("card.pair"));
|
||||||
menu.append(Some("Test network speed…"), Some("card.speed"));
|
menu.append(Some("Test network speed…"), Some("card.speed"));
|
||||||
|
// Offer an explicit wake only when the host is offline and we actually have a MAC to target.
|
||||||
|
if !online && !k.mac.is_empty() {
|
||||||
|
menu.append(Some("Wake host"), Some("card.wake"));
|
||||||
|
}
|
||||||
// Experimental (Preferences gate, Apple parity): browse the host's game library. The
|
// Experimental (Preferences gate, Apple parity): browse the host's game library. The
|
||||||
// item is offered on every saved card — an unpaired host answers with the friendly
|
// item is offered on every saved card — an unpaired host answers with the friendly
|
||||||
// "not paired" error state rather than the entry hiding itself.
|
// "not paired" error state rather than the entry hiding itself.
|
||||||
@@ -512,7 +546,16 @@ fn saved_card(
|
|||||||
overlay.add_controller(right_click);
|
overlay.add_controller(right_click);
|
||||||
|
|
||||||
let on_connect = state.cbs.on_connect.clone();
|
let on_connect = state.cbs.on_connect.clone();
|
||||||
child.connect_activate(move |_| on_connect(req.clone()));
|
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, fire a
|
||||||
|
// magic packet before connecting — the connect's own retry/timeout gives a woken host time to
|
||||||
|
// come up. A host that's genuinely off/unreachable then fails the connect as before.
|
||||||
|
let wake_first = !online && !req.mac.is_empty();
|
||||||
|
child.connect_activate(move |_| {
|
||||||
|
if wake_first {
|
||||||
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
|
}
|
||||||
|
on_connect(req.clone());
|
||||||
|
});
|
||||||
child
|
child
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +573,7 @@ fn discovered_card(
|
|||||||
// required/empty means mandatory PIN.
|
// required/empty means mandatory PIN.
|
||||||
pair_optional: a.pair == "optional",
|
pair_optional: a.pair == "optional",
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: a.mac.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
@@ -665,6 +709,7 @@ fn add_host_dialog(state: &Rc<State>) {
|
|||||||
// Manual entry carries no advertised policy — never eligible for TOFU.
|
// Manual entry carries no advertised policy — never eligible for TOFU.
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: Vec::new(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
|||||||
.row_spacing(18)
|
.row_spacing(18)
|
||||||
.valign(gtk::Align::Start)
|
.valign(gtk::Align::Start)
|
||||||
.build();
|
.build();
|
||||||
|
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
|
||||||
|
// `activate` — bridge it so each poster's connect handler (below) runs on click.
|
||||||
|
flow.connect_child_activated(|_, child| {
|
||||||
|
child.activate();
|
||||||
|
});
|
||||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
content.set_margin_top(24);
|
content.set_margin_top(24);
|
||||||
content.set_margin_bottom(24);
|
content.set_margin_bottom(24);
|
||||||
|
|||||||
+125
-16
@@ -111,6 +111,10 @@ pub struct StreamPageArgs {
|
|||||||
pub window: adw::ApplicationWindow,
|
pub window: adw::ApplicationWindow,
|
||||||
pub connector: Arc<NativeClient>,
|
pub connector: Arc<NativeClient>,
|
||||||
pub frames: async_channel::Receiver<DecodedFrame>,
|
pub frames: async_channel::Receiver<DecodedFrame>,
|
||||||
|
/// Shared with the session pump: the presenter raises it when hardware frames can't
|
||||||
|
/// be displayed (GL converter init failed / dmabuf import rejected) and the pump
|
||||||
|
/// demotes the decoder to software.
|
||||||
|
pub force_software: Arc<AtomicBool>,
|
||||||
/// Host-clock offset from the session's clock handshake — added to the local wall
|
/// Host-clock offset from the session's clock handshake — added to the local wall
|
||||||
/// clock to express paintable-set time in the host's capture clock (present latency).
|
/// clock to express paintable-set time in the host's capture clock (present latency).
|
||||||
pub clock_offset_ns: i64,
|
pub clock_offset_ns: i64,
|
||||||
@@ -167,7 +171,13 @@ fn send_abs(widget: &impl IsA<gtk::Widget>, connector: &NativeClient, x: f64, y:
|
|||||||
struct Capture {
|
struct Capture {
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
window: adw::ApplicationWindow,
|
window: adw::ApplicationWindow,
|
||||||
overlay: gtk::Overlay,
|
/// Held WEAKLY. Every input controller + the frame-clock tick are added to this overlay
|
||||||
|
/// and each captures `Rc<Capture>`; a strong ref back here would close the cycle
|
||||||
|
/// `overlay → controller → Rc<Capture> → overlay` that GTK can't collect, leaking the
|
||||||
|
/// whole stream subtree AND the `Arc<NativeClient>` (so `NativeClient::Drop` never runs)
|
||||||
|
/// on every session end — unbounded growth across the reconnects a Deck does constantly.
|
||||||
|
/// The live widget tree owns the overlay for the session's lifetime; upgrade at use.
|
||||||
|
overlay: glib::WeakRef<gtk::Overlay>,
|
||||||
hint: gtk::Label,
|
hint: gtk::Label,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
captured: Cell<bool>,
|
captured: Cell<bool>,
|
||||||
@@ -181,13 +191,19 @@ struct Capture {
|
|||||||
/// VKs / GameStream button ids currently held — flushed up on release.
|
/// VKs / GameStream button ids currently held — flushed up on release.
|
||||||
held_keys: RefCell<HashSet<u8>>,
|
held_keys: RefCell<HashSet<u8>>,
|
||||||
held_buttons: RefCell<HashSet<u32>>,
|
held_buttons: RefCell<HashSet<u32>>,
|
||||||
|
/// Fractional wheel remainder per axis (x, y), in 120-unit WHEEL_DELTA space. Precision
|
||||||
|
/// scroll surfaces — the Deck trackpad, hi-res wheels, two-finger touchpad — deliver
|
||||||
|
/// sub-unit deltas; truncating each event drops the tail. Carry it here instead.
|
||||||
|
scroll_acc: Cell<(f64, f64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Capture {
|
impl Capture {
|
||||||
/// Send the coalesced pointer position, if any — one datagram, one fresh mode read.
|
/// Send the coalesced pointer position, if any — one datagram, one fresh mode read.
|
||||||
fn flush_pending_motion(&self) {
|
fn flush_pending_motion(&self) {
|
||||||
if let Some((x, y)) = self.pending_abs.take() {
|
if let Some((x, y)) = self.pending_abs.take() {
|
||||||
send_abs(&self.overlay, &self.connector, x, y);
|
if let Some(overlay) = self.overlay.upgrade() {
|
||||||
|
send_abs(&overlay, &self.connector, x, y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +211,9 @@ impl Capture {
|
|||||||
if self.captured.replace(true) {
|
if self.captured.replace(true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.overlay
|
if let Some(overlay) = self.overlay.upgrade() {
|
||||||
.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
|
overlay.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
|
||||||
|
}
|
||||||
self.hint.set_visible(false);
|
self.hint.set_visible(false);
|
||||||
if self.inhibit_shortcuts {
|
if self.inhibit_shortcuts {
|
||||||
if let Some(tl) = self
|
if let Some(tl) = self
|
||||||
@@ -213,7 +230,9 @@ impl Capture {
|
|||||||
if !self.captured.replace(false) {
|
if !self.captured.replace(false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.overlay.set_cursor(None);
|
if let Some(overlay) = self.overlay.upgrade() {
|
||||||
|
overlay.set_cursor(None);
|
||||||
|
}
|
||||||
self.hint.set_visible(true);
|
self.hint.set_visible(true);
|
||||||
self.pending_abs.set(None); // never flush motion gathered while captured
|
self.pending_abs.set(None); // never flush motion gathered while captured
|
||||||
if let Some(tl) = self
|
if let Some(tl) = self
|
||||||
@@ -238,6 +257,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
|||||||
window,
|
window,
|
||||||
connector,
|
connector,
|
||||||
frames,
|
frames,
|
||||||
|
force_software,
|
||||||
clock_offset_ns,
|
clock_offset_ns,
|
||||||
escape_rx,
|
escape_rx,
|
||||||
disconnect_rx,
|
disconnect_rx,
|
||||||
@@ -261,13 +281,14 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
|||||||
let capture = Rc::new(Capture {
|
let capture = Rc::new(Capture {
|
||||||
connector,
|
connector,
|
||||||
window: window.clone(),
|
window: window.clone(),
|
||||||
overlay: w.overlay.clone(),
|
overlay: w.overlay.downgrade(),
|
||||||
hint: w.hint.clone(),
|
hint: w.hint.clone(),
|
||||||
inhibit_shortcuts,
|
inhibit_shortcuts,
|
||||||
captured: Cell::new(false),
|
captured: Cell::new(false),
|
||||||
pending_abs: Cell::new(None),
|
pending_abs: Cell::new(None),
|
||||||
held_keys: RefCell::new(HashSet::new()),
|
held_keys: RefCell::new(HashSet::new()),
|
||||||
held_buttons: RefCell::new(HashSet::new()),
|
held_buttons: RefCell::new(HashSet::new()),
|
||||||
|
scroll_acc: Cell::new((0.0, 0.0)),
|
||||||
});
|
});
|
||||||
|
|
||||||
let presented = Rc::new(PresentedStats::default());
|
let presented = Rc::new(PresentedStats::default());
|
||||||
@@ -275,11 +296,12 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
|||||||
spawn_frame_consumer(
|
spawn_frame_consumer(
|
||||||
&w.picture,
|
&w.picture,
|
||||||
frames,
|
frames,
|
||||||
|
force_software,
|
||||||
clock_offset_ns,
|
clock_offset_ns,
|
||||||
presented.clone(),
|
presented.clone(),
|
||||||
hdr.clone(),
|
hdr.clone(),
|
||||||
);
|
);
|
||||||
attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label);
|
let key_controller = attach_keyboard(&window, &capture, &stop, &w.stats_label);
|
||||||
attach_mouse(&w.overlay, &capture);
|
attach_mouse(&w.overlay, &capture);
|
||||||
attach_scroll(&w.overlay, &capture);
|
attach_scroll(&w.overlay, &capture);
|
||||||
if !chromeless {
|
if !chromeless {
|
||||||
@@ -293,6 +315,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
|||||||
&window,
|
&window,
|
||||||
&stop,
|
&stop,
|
||||||
(w.fs_handler, active_handler),
|
(w.fs_handler, active_handler),
|
||||||
|
key_controller,
|
||||||
escape_future,
|
escape_future,
|
||||||
disconnect_future,
|
disconnect_future,
|
||||||
);
|
);
|
||||||
@@ -567,9 +590,33 @@ impl ColorStateCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How hardware (dmabuf) frames reach the screen.
|
||||||
|
#[derive(PartialEq, Clone, Copy)]
|
||||||
|
enum HwPresent {
|
||||||
|
/// Hand the NV12 dmabuf straight to `GdkDmabufTexture` — GTK (or the compositor via
|
||||||
|
/// offload) imports + converts. The desktop default: subsurface/scan-out eligible.
|
||||||
|
Direct,
|
||||||
|
/// Convert in-process first (`video_gl`): own EGL import + own YUV→RGB shader → RGBA
|
||||||
|
/// `GdkGLTexture`. The Steam Deck default — GTK's tiled-NV12 import is broken there
|
||||||
|
/// (Mesa ≥ 25.1 tiled VCN export), and this is the Moonlight-proven route around it.
|
||||||
|
Gl,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HwPresent {
|
||||||
|
fn pick() -> HwPresent {
|
||||||
|
match std::env::var("PUNKTFUNK_PRESENT").ok().as_deref() {
|
||||||
|
Some("direct") => HwPresent::Direct,
|
||||||
|
Some("gl") => HwPresent::Gl,
|
||||||
|
_ if crate::gamepad::is_steam_deck() => HwPresent::Gl,
|
||||||
|
_ => HwPresent::Direct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_frame_consumer(
|
fn spawn_frame_consumer(
|
||||||
picture: >k::Picture,
|
picture: >k::Picture,
|
||||||
frames: async_channel::Receiver<DecodedFrame>,
|
frames: async_channel::Receiver<DecodedFrame>,
|
||||||
|
force_software: Arc<AtomicBool>,
|
||||||
clock_offset_ns: i64,
|
clock_offset_ns: i64,
|
||||||
presented_stats: Rc<PresentedStats>,
|
presented_stats: Rc<PresentedStats>,
|
||||||
hdr: Rc<Cell<bool>>,
|
hdr: Rc<Cell<bool>>,
|
||||||
@@ -582,6 +629,11 @@ fn spawn_frame_consumer(
|
|||||||
// (SDR↔HDR flip) just rebuilds once.
|
// (SDR↔HDR flip) just rebuilds once.
|
||||||
let mut yuv_state = ColorStateCache::default();
|
let mut yuv_state = ColorStateCache::default();
|
||||||
let mut rgb_state = ColorStateCache::default();
|
let mut rgb_state = ColorStateCache::default();
|
||||||
|
let hw_present = HwPresent::pick();
|
||||||
|
// Lazy (first dmabuf frame) so software-decode sessions never touch EGL. `Err` after
|
||||||
|
// a failed init = don't retry every frame.
|
||||||
|
let mut gl_conv: Option<Result<crate::video_gl::GlConverter, ()>> = None;
|
||||||
|
let mut gl_fails = 0u32;
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
// Window samples (µs): end-to-end capture→displayed (host-clock corrected) and
|
// Window samples (µs): end-to-end capture→displayed (host-clock corrected) and
|
||||||
// the client-local display stage decoded→displayed.
|
// the client-local display stage decoded→displayed.
|
||||||
@@ -629,6 +681,39 @@ fn spawn_frame_consumer(
|
|||||||
picture.set_paintable(Some(&tex));
|
picture.set_paintable(Some(&tex));
|
||||||
presented = true;
|
presented = true;
|
||||||
}
|
}
|
||||||
|
DecodedImage::Dmabuf(d) if hw_present == HwPresent::Gl => {
|
||||||
|
// In-process conversion (see `HwPresent::Gl`). Init once; a failed
|
||||||
|
// init or a streak of convert failures demotes the DECODER to
|
||||||
|
// software via the shared flag — never fall back to the direct path
|
||||||
|
// here, it's the known-broken one on this hardware.
|
||||||
|
let conv = gl_conv.get_or_insert_with(|| {
|
||||||
|
crate::video_gl::GlConverter::new(&picture).map_err(|e| {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
|
"GL presenter unavailable — demoting to software decode");
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match conv {
|
||||||
|
Ok(c) => {
|
||||||
|
let color = d.color;
|
||||||
|
match c.convert(d, rgb_state.get(color, true).as_ref()) {
|
||||||
|
Ok(tex) => {
|
||||||
|
gl_fails = 0;
|
||||||
|
picture.set_paintable(Some(&tex));
|
||||||
|
presented = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
gl_fails += 1;
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), fails = gl_fails,
|
||||||
|
"GL convert failed");
|
||||||
|
if gl_fails >= 3 {
|
||||||
|
force_software.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(()) => force_software.store(true, Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
DecodedImage::Dmabuf(d) => {
|
DecodedImage::Dmabuf(d) => {
|
||||||
let mut b = gdk::DmabufTextureBuilder::new()
|
let mut b = gdk::DmabufTextureBuilder::new()
|
||||||
.set_display(&picture.display())
|
.set_display(&picture.display())
|
||||||
@@ -696,13 +781,20 @@ fn spawn_frame_consumer(
|
|||||||
/// Keyboard, capture-phase: the release (Ctrl+Alt+Shift+Q) / disconnect (Ctrl+Alt+Shift+D)
|
/// Keyboard, capture-phase: the release (Ctrl+Alt+Shift+Q) / disconnect (Ctrl+Alt+Shift+D)
|
||||||
/// / stats (Ctrl+Alt+Shift+S) chords and F11 are handled locally; everything else becomes
|
/// / stats (Ctrl+Alt+Shift+S) chords and F11 are handled locally; everything else becomes
|
||||||
/// a VK on the wire while captured.
|
/// a VK on the wire while captured.
|
||||||
|
///
|
||||||
|
/// The controller lives on the **window**, not the stream overlay: a `NavigationView` push
|
||||||
|
/// followed by `window.fullscreen()` hands keyboard focus to the pushed page's header back
|
||||||
|
/// button (a sibling of the overlay), so an overlay-scoped key controller never sees a key and
|
||||||
|
/// every chord — plus all gameplay key forwarding — is silently dropped until the user clicks
|
||||||
|
/// the stream. The window is always on the key-propagation path regardless of which child holds
|
||||||
|
/// focus. Returned so `wire_teardown` can remove it when the page goes away (otherwise the
|
||||||
|
/// chords would keep firing app-wide against a dead session).
|
||||||
fn attach_keyboard(
|
fn attach_keyboard(
|
||||||
overlay: >k::Overlay,
|
|
||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
capture: &Rc<Capture>,
|
capture: &Rc<Capture>,
|
||||||
stop: &Arc<AtomicBool>,
|
stop: &Arc<AtomicBool>,
|
||||||
stats: >k::Label,
|
stats: >k::Label,
|
||||||
) {
|
) -> gtk::EventControllerKey {
|
||||||
let key = gtk::EventControllerKey::new();
|
let key = gtk::EventControllerKey::new();
|
||||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||||
let cap = capture.clone();
|
let cap = capture.clone();
|
||||||
@@ -768,7 +860,8 @@ fn attach_keyboard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
overlay.add_controller(key);
|
window.add_controller(key.clone());
|
||||||
|
key
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mouse: absolute motion + buttons — forwarded only while captured; the click that
|
/// Mouse: absolute motion + buttons — forwarded only while captured; the click that
|
||||||
@@ -787,7 +880,8 @@ fn attach_mouse(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
|||||||
});
|
});
|
||||||
overlay.add_controller(motion);
|
overlay.add_controller(motion);
|
||||||
|
|
||||||
// The per-tick flush. (The tick callback dies with the overlay, so no teardown.)
|
// The per-tick flush. The tick callback dies with the overlay (which `Capture` now holds
|
||||||
|
// only weakly, so it truly can), taking its `Capture` ref with it — no explicit teardown.
|
||||||
let cap = capture.clone();
|
let cap = capture.clone();
|
||||||
overlay.add_tick_callback(move |_, _| {
|
overlay.add_tick_callback(move |_, _| {
|
||||||
cap.flush_pending_motion();
|
cap.flush_pending_motion();
|
||||||
@@ -797,7 +891,9 @@ fn attach_mouse(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
|||||||
let click = gtk::GestureClick::builder().button(0).build();
|
let click = gtk::GestureClick::builder().button(0).build();
|
||||||
let cap = capture.clone();
|
let cap = capture.clone();
|
||||||
click.connect_pressed(move |g, _n, x, y| {
|
click.connect_pressed(move |g, _n, x, y| {
|
||||||
cap.overlay.grab_focus();
|
if let Some(overlay) = cap.overlay.upgrade() {
|
||||||
|
overlay.grab_focus();
|
||||||
|
}
|
||||||
if !cap.captured.get() {
|
if !cap.captured.get() {
|
||||||
cap.engage(); // the engaging click is suppressed toward the host
|
cap.engage(); // the engaging click is suppressed toward the host
|
||||||
return;
|
return;
|
||||||
@@ -833,16 +929,22 @@ fn attach_scroll(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
|||||||
}
|
}
|
||||||
cap.flush_pending_motion(); // scroll happens at the latest cursor position
|
cap.flush_pending_motion(); // scroll happens at the latest cursor position
|
||||||
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
|
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
|
||||||
// positive = down. Smooth fractions survive — libei's discrete scroll is
|
// positive = down. libei's discrete scroll is 120-based too. Accumulate the
|
||||||
// 120-based too.
|
// fractional remainder so precision-scroll sub-unit deltas aren't lost.
|
||||||
let vy = (-dy * 120.0) as i32;
|
let (mut ax, mut ay) = cap.scroll_acc.get();
|
||||||
|
ay += -dy * 120.0;
|
||||||
|
ax += dx * 120.0;
|
||||||
|
let vy = ay.trunc() as i32;
|
||||||
if vy != 0 {
|
if vy != 0 {
|
||||||
|
ay -= f64::from(vy);
|
||||||
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
|
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
|
||||||
}
|
}
|
||||||
let vx = (dx * 120.0) as i32;
|
let vx = ax.trunc() as i32;
|
||||||
if vx != 0 {
|
if vx != 0 {
|
||||||
|
ax -= f64::from(vx);
|
||||||
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
||||||
}
|
}
|
||||||
|
cap.scroll_acc.set((ax, ay));
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
});
|
});
|
||||||
overlay.add_controller(scroll);
|
overlay.add_controller(scroll);
|
||||||
@@ -938,12 +1040,14 @@ fn wire_teardown(
|
|||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
stop: &Arc<AtomicBool>,
|
stop: &Arc<AtomicBool>,
|
||||||
handlers: (glib::SignalHandlerId, glib::SignalHandlerId),
|
handlers: (glib::SignalHandlerId, glib::SignalHandlerId),
|
||||||
|
key_controller: gtk::EventControllerKey,
|
||||||
escape_future: glib::JoinHandle<()>,
|
escape_future: glib::JoinHandle<()>,
|
||||||
disconnect_future: glib::JoinHandle<()>,
|
disconnect_future: glib::JoinHandle<()>,
|
||||||
) {
|
) {
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
let stop_h = stop.clone();
|
let stop_h = stop.clone();
|
||||||
let handlers = RefCell::new(Some(handlers));
|
let handlers = RefCell::new(Some(handlers));
|
||||||
|
let key_controller = RefCell::new(Some(key_controller));
|
||||||
let escape_future = RefCell::new(Some(escape_future));
|
let escape_future = RefCell::new(Some(escape_future));
|
||||||
let disconnect_future = RefCell::new(Some(disconnect_future));
|
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||||
page.connect_hidden(move |_| {
|
page.connect_hidden(move |_| {
|
||||||
@@ -952,6 +1056,11 @@ fn wire_teardown(
|
|||||||
window.disconnect(fs);
|
window.disconnect(fs);
|
||||||
window.disconnect(active);
|
window.disconnect(active);
|
||||||
}
|
}
|
||||||
|
// The key controller lives on the window (see `attach_keyboard`) — remove it so its
|
||||||
|
// chords don't keep firing app-wide against a torn-down session.
|
||||||
|
if let Some(kc) = key_controller.borrow_mut().take() {
|
||||||
|
window.remove_controller(&kc);
|
||||||
|
}
|
||||||
if let Some(f) = escape_future.borrow_mut().take() {
|
if let Some(f) = escape_future.borrow_mut().take() {
|
||||||
f.abort();
|
f.abort();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,19 @@ pub struct Decoder {
|
|||||||
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
|
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
|
||||||
/// rebuilds the software decoder for the SAME codec.
|
/// rebuilds the software decoder for the SAME codec.
|
||||||
codec_id: ffmpeg::codec::Id,
|
codec_id: ffmpeg::codec::Id,
|
||||||
|
/// Consecutive VAAPI decode errors — a single transient failure (e.g. a reference-missing
|
||||||
|
/// frame after packet loss) shouldn't cost the whole session its hardware decoder.
|
||||||
|
vaapi_fails: u32,
|
||||||
|
/// Set when the decoder needs a fresh IDR to resynchronize (after an error or a demotion).
|
||||||
|
/// The pump drains it and asks the host — under the infinite GOP there is no periodic
|
||||||
|
/// keyframe, so a rebuilt/erroring decoder would otherwise stay gray/frozen forever.
|
||||||
|
want_keyframe: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Demote VAAPI→software only after this many consecutive hardware decode errors; a lone
|
||||||
|
/// transient error just re-requests an IDR and keeps the hardware decoder.
|
||||||
|
const VAAPI_DEMOTE_AFTER: u32 = 3;
|
||||||
|
|
||||||
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||||
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||||
match wire {
|
match wire {
|
||||||
@@ -176,6 +187,12 @@ impl Decoder {
|
|||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
.unwrap_or_else(|| pref.to_string());
|
.unwrap_or_else(|| pref.to_string());
|
||||||
|
// Deck note: `auto` means VAAPI here too. GTK's tiled-NV12 dmabuf import is broken on
|
||||||
|
// the Deck (Mesa ≥ 25.1 exports VCN surfaces TILED; artifacts/gray/washed-out), but the
|
||||||
|
// presenter routes Deck frames through the in-process GL converter (`video_gl`) instead
|
||||||
|
// of GdkDmabufTexture — and if THAT can't initialize, it demotes this decoder to
|
||||||
|
// software mid-session via [`Decoder::force_software`]. The broken direct path is never
|
||||||
|
// the fallback.
|
||||||
if choice != "software" {
|
if choice != "software" {
|
||||||
match VaapiDecoder::new(codec_id) {
|
match VaapiDecoder::new(codec_id) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
@@ -183,6 +200,8 @@ impl Decoder {
|
|||||||
return Ok(Decoder {
|
return Ok(Decoder {
|
||||||
backend: Backend::Vaapi(v),
|
backend: Backend::Vaapi(v),
|
||||||
codec_id,
|
codec_id,
|
||||||
|
vaapi_fails: 0,
|
||||||
|
want_keyframe: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -196,20 +215,58 @@ impl Decoder {
|
|||||||
Ok(Decoder {
|
Ok(Decoder {
|
||||||
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
||||||
codec_id,
|
codec_id,
|
||||||
|
vaapi_fails: 0,
|
||||||
|
want_keyframe: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
|
||||||
|
/// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP.
|
||||||
|
pub fn take_keyframe_request(&mut self) -> bool {
|
||||||
|
std::mem::take(&mut self.want_keyframe)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Demote to software decode on the PRESENTER's verdict (dmabuf presentation impossible:
|
||||||
|
/// GL converter init failed, texture import rejected). Decode itself succeeds in that
|
||||||
|
/// state, so the error-streak demotion never fires — without this the stream would stay
|
||||||
|
/// black forever. No-op when already software.
|
||||||
|
pub fn force_software(&mut self) -> Result<()> {
|
||||||
|
if matches!(self.backend, Backend::Software(_)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
tracing::warn!("presenter can't display hardware frames — demoting to software decode");
|
||||||
|
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
|
self.vaapi_fails = 0;
|
||||||
|
self.want_keyframe = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed one access unit; returns the decoded frame (the host's streams are
|
/// Feed one access unit; returns the decoded frame (the host's streams are
|
||||||
/// one-in/one-out). A software decode error after packet loss is survivable — log
|
/// one-in/one-out). A software decode error after packet loss is survivable — log
|
||||||
/// upstream and keep feeding. A VAAPI error demotes to software for the rest of the
|
/// upstream and keep feeding. A VAAPI error re-requests an IDR and retries the hardware
|
||||||
/// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes.
|
/// decoder; only a persistent streak of failures (a genuinely broken driver, e.g.
|
||||||
|
/// nvidia-vaapi-driver) demotes to software. Either way `want_keyframe` is set so the
|
||||||
|
/// pump asks the host for a fresh IDR — under the infinite GOP nothing else resyncs a
|
||||||
|
/// rebuilt/erroring decoder, so skipping this leaves the picture gray/frozen for good.
|
||||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
||||||
match &mut self.backend {
|
match &mut self.backend {
|
||||||
Backend::Vaapi(v) => match v.decode(au) {
|
Backend::Vaapi(v) => match v.decode(au) {
|
||||||
Ok(f) => Ok(f.map(DecodedImage::Dmabuf)),
|
Ok(f) => {
|
||||||
|
self.vaapi_fails = 0;
|
||||||
|
Ok(f.map(DecodedImage::Dmabuf))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
|
self.vaapi_fails += 1;
|
||||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
self.want_keyframe = true;
|
||||||
|
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||||
|
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||||
|
"VAAPI decode failing repeatedly — demoting to software");
|
||||||
|
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
|
self.vaapi_fails = 0;
|
||||||
|
} else {
|
||||||
|
tracing::warn!(error = %e,
|
||||||
|
"VAAPI decode error — requesting keyframe, keeping hardware decode");
|
||||||
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -420,6 +477,14 @@ impl VaapiDecoder {
|
|||||||
(*ctx).get_format = Some(pick_vaapi);
|
(*ctx).get_format = Some(pick_vaapi);
|
||||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||||
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||||
|
|
||||||
|
// The presenter holds mapped surfaces PAST receive_frame (the paintable's
|
||||||
|
// current texture + the newest frame in flight each pin one until GDK's
|
||||||
|
// release func) — surfaces libavcodec doesn't know are missing from its
|
||||||
|
// fixed-size VAAPI pool. Without headroom the decoder can recycle a surface
|
||||||
|
// the renderer is still sampling (intermittent block corruption) or fail
|
||||||
|
// allocation under scheduling jitter.
|
||||||
|
(*ctx).extra_hw_frames = 4;
|
||||||
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
|
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
let mut ctx = ctx;
|
let mut ctx = ctx;
|
||||||
|
|||||||
@@ -0,0 +1,662 @@
|
|||||||
|
//! VAAPI dmabuf → RGBA GL texture converter — the Steam Deck's hardware-decode presenter.
|
||||||
|
//!
|
||||||
|
//! The direct path hands the decoder's NV12 dmabuf (fds + AMD tiled modifier) to
|
||||||
|
//! `GdkDmabufTexture` and lets GTK import + color-convert it. On the Deck that renders
|
||||||
|
//! corrupt/gray/washed-out: since Mesa 25.1 radeonsi exports VCN decode surfaces TILED, and
|
||||||
|
//! GTK's tiled-NV12 import mishandles the layout (the Flatpak runtime's Mesa drives both
|
||||||
|
//! sides). Moonlight-qt and mpv are clean on the same box because they never let a toolkit
|
||||||
|
//! near the YUV: they import the dmabuf into their own EGL context and convert with their
|
||||||
|
//! own shader. This module is that architecture for the GTK client:
|
||||||
|
//!
|
||||||
|
//! VAAPI frame → per-plane `EGLImage`s (R8 luma + GR88 chroma, modifier passed through)
|
||||||
|
//! → our YUV→RGB shader (matrix + range from the stream's real CICP signaling)
|
||||||
|
//! → an RGBA texture in a `GdkGLContext`-shared context → `GdkGLTexture` (fence-synced).
|
||||||
|
//!
|
||||||
|
//! GTK then composites a plain RGBA texture — no YUV format negotiation, no modifier
|
||||||
|
//! handling, no compositor CSC. Same-Mesa export/import is the exact proven-working path.
|
||||||
|
//! Everything runs on the GTK main thread (the converter is driven by the frame consumer);
|
||||||
|
//! one 800p–4K NV12→RGB pass is sub-millisecond GPU work.
|
||||||
|
//!
|
||||||
|
//! Failure at any step (GLX-backed GDK context, missing EGL extensions, import rejection)
|
||||||
|
//! is surfaced as an error — the caller falls back to software decode, never to the broken
|
||||||
|
//! direct path.
|
||||||
|
|
||||||
|
use crate::video::{ColorDesc, DmabufFrame};
|
||||||
|
use anyhow::{anyhow, bail, Context as _, Result};
|
||||||
|
use gtk::{gdk, prelude::*};
|
||||||
|
use khronos_egl as egl;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
// --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------
|
||||||
|
const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270;
|
||||||
|
const EGL_LINUX_DRM_FOURCC_EXT: usize = 0x3271;
|
||||||
|
const EGL_DMA_BUF_PLANE0_FD_EXT: usize = 0x3272;
|
||||||
|
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: usize = 0x3273;
|
||||||
|
const EGL_DMA_BUF_PLANE0_PITCH_EXT: usize = 0x3274;
|
||||||
|
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: usize = 0x3443;
|
||||||
|
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: usize = 0x3444;
|
||||||
|
const EGL_WIDTH: usize = 0x3057;
|
||||||
|
const EGL_HEIGHT: usize = 0x3056;
|
||||||
|
const EGL_NONE: usize = 0x3038;
|
||||||
|
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
|
||||||
|
|
||||||
|
/// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when
|
||||||
|
/// the Linux host grows 10-bit.
|
||||||
|
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
|
||||||
|
const DRM_FORMAT_R8: u32 = 0x2020_3852;
|
||||||
|
const DRM_FORMAT_GR88: u32 = 0x3838_5247;
|
||||||
|
|
||||||
|
// --- The slice of GL we use (loaded via eglGetProcAddress — Mesa/NVIDIA both implement
|
||||||
|
// --- EGL_KHR_get_all_proc_addresses, so core functions resolve too) ----------------------
|
||||||
|
const GL_TEXTURE_2D: u32 = 0x0DE1;
|
||||||
|
const GL_TEXTURE0: u32 = 0x84C0;
|
||||||
|
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
||||||
|
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
|
||||||
|
const GL_TEXTURE_WRAP_S: u32 = 0x2802;
|
||||||
|
const GL_TEXTURE_WRAP_T: u32 = 0x2803;
|
||||||
|
const GL_LINEAR: i32 = 0x2601;
|
||||||
|
const GL_CLAMP_TO_EDGE: i32 = 0x812F;
|
||||||
|
const GL_FRAMEBUFFER: u32 = 0x8D40;
|
||||||
|
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
|
||||||
|
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
|
||||||
|
const GL_RGBA8: u32 = 0x8058;
|
||||||
|
const GL_RGBA: u32 = 0x1908;
|
||||||
|
const GL_UNSIGNED_BYTE: u32 = 0x1401;
|
||||||
|
const GL_TRIANGLES: u32 = 0x0004;
|
||||||
|
const GL_VERTEX_SHADER: u32 = 0x8B31;
|
||||||
|
const GL_FRAGMENT_SHADER: u32 = 0x8B30;
|
||||||
|
const GL_COMPILE_STATUS: u32 = 0x8B81;
|
||||||
|
const GL_LINK_STATUS: u32 = 0x8B82;
|
||||||
|
const GL_SYNC_GPU_COMMANDS_COMPLETE: u32 = 0x9117;
|
||||||
|
|
||||||
|
macro_rules! gl_fns {
|
||||||
|
($($name:ident : fn($($arg:ty),*) $(-> $ret:ty)?;)*) => {
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
struct GlFns { $($name: unsafe extern "C" fn($($arg),*) $(-> $ret)?,)* }
|
||||||
|
impl GlFns {
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
fn load(egl: &Egl) -> Result<GlFns> {
|
||||||
|
$(
|
||||||
|
// eglGetProcAddress returns a plain fn pointer; the signature is fixed
|
||||||
|
// by the GL spec for each name.
|
||||||
|
let $name = egl
|
||||||
|
.get_proc_address(concat!("gl", stringify!($name)))
|
||||||
|
.ok_or_else(|| anyhow!(concat!("gl", stringify!($name), " unresolvable")))?;
|
||||||
|
)*
|
||||||
|
// SAFETY: each pointer came from eglGetProcAddress for exactly that GL entry
|
||||||
|
// point; the transmute only fixes the signature the spec defines for it.
|
||||||
|
unsafe {
|
||||||
|
Ok(GlFns { $($name: std::mem::transmute::<extern "system" fn(), unsafe extern "C" fn($($arg),*) $(-> $ret)?>($name),)* })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_fns! {
|
||||||
|
GenTextures: fn(i32, *mut u32);
|
||||||
|
DeleteTextures: fn(i32, *const u32);
|
||||||
|
BindTexture: fn(u32, u32);
|
||||||
|
TexParameteri: fn(u32, u32, i32);
|
||||||
|
TexImage2D: fn(u32, i32, i32, i32, i32, i32, u32, u32, *const c_void);
|
||||||
|
ActiveTexture: fn(u32);
|
||||||
|
EGLImageTargetTexture2DOES: fn(u32, *const c_void);
|
||||||
|
GenFramebuffers: fn(i32, *mut u32);
|
||||||
|
DeleteFramebuffers: fn(i32, *const u32);
|
||||||
|
BindFramebuffer: fn(u32, u32);
|
||||||
|
FramebufferTexture2D: fn(u32, u32, u32, u32, i32);
|
||||||
|
CheckFramebufferStatus: fn(u32) -> u32;
|
||||||
|
Viewport: fn(i32, i32, i32, i32);
|
||||||
|
CreateShader: fn(u32) -> u32;
|
||||||
|
ShaderSource: fn(u32, i32, *const *const u8, *const i32);
|
||||||
|
CompileShader: fn(u32);
|
||||||
|
GetShaderiv: fn(u32, u32, *mut i32);
|
||||||
|
GetShaderInfoLog: fn(u32, i32, *mut i32, *mut u8);
|
||||||
|
DeleteShader: fn(u32);
|
||||||
|
CreateProgram: fn() -> u32;
|
||||||
|
AttachShader: fn(u32, u32);
|
||||||
|
LinkProgram: fn(u32);
|
||||||
|
GetProgramiv: fn(u32, u32, *mut i32);
|
||||||
|
UseProgram: fn(u32);
|
||||||
|
GetUniformLocation: fn(u32, *const u8) -> i32;
|
||||||
|
Uniform1i: fn(i32, i32);
|
||||||
|
Uniform3fv: fn(i32, i32, *const f32);
|
||||||
|
UniformMatrix3fv: fn(i32, i32, u8, *const f32);
|
||||||
|
GenVertexArrays: fn(i32, *mut u32);
|
||||||
|
DeleteVertexArrays: fn(i32, *const u32);
|
||||||
|
DeleteProgram: fn(u32);
|
||||||
|
BindVertexArray: fn(u32);
|
||||||
|
DrawArrays: fn(u32, i32, i32);
|
||||||
|
FenceSync: fn(u32, u32) -> *const c_void;
|
||||||
|
DeleteSync: fn(*const c_void);
|
||||||
|
Flush: fn();
|
||||||
|
GetError: fn() -> u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Egl = egl::DynamicInstance<egl::EGL1_4>;
|
||||||
|
type EglCreateImageKhr = unsafe extern "C" fn(
|
||||||
|
*mut c_void, // EGLDisplay
|
||||||
|
*mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf)
|
||||||
|
egl::Enum,
|
||||||
|
*mut c_void, // EGLClientBuffer (null for dmabuf)
|
||||||
|
*const usize,
|
||||||
|
) -> *const c_void;
|
||||||
|
type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean;
|
||||||
|
|
||||||
|
/// The YUV→RGB conversion for a stream's CICP signaling: `rgb = mat * (yuv + off)`, with the
|
||||||
|
/// limited/full-range expansion folded in. `mat` is column-major (GL convention). Pure —
|
||||||
|
/// unit-tested against the reference white/black points.
|
||||||
|
pub fn yuv_to_rgb(desc: ColorDesc) -> ([f32; 9], [f32; 3]) {
|
||||||
|
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
||||||
|
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
||||||
|
let (kr, kb) = match desc.matrix {
|
||||||
|
5 | 6 => (0.299, 0.114),
|
||||||
|
9 | 10 => (0.2627, 0.0593),
|
||||||
|
_ => (0.2126, 0.0722),
|
||||||
|
};
|
||||||
|
let kg = 1.0 - kr - kb;
|
||||||
|
let (sy, oy, sc) = if desc.full_range {
|
||||||
|
(1.0f32, 0.0f32, 1.0f32)
|
||||||
|
} else {
|
||||||
|
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
||||||
|
};
|
||||||
|
let (kr, kb, kg) = (kr as f32, kb as f32, kg as f32);
|
||||||
|
// Column-major: columns are the Y, U, V contributions to (R, G, B).
|
||||||
|
let mat = [
|
||||||
|
sy,
|
||||||
|
sy,
|
||||||
|
sy, // Y column
|
||||||
|
0.0,
|
||||||
|
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||||
|
2.0 * (1.0 - kb) * sc, // U column
|
||||||
|
2.0 * (1.0 - kr) * sc,
|
||||||
|
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||||
|
0.0, // V column
|
||||||
|
];
|
||||||
|
(mat, [oy, -0.5, -0.5])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An output texture GTK has released, waiting to be recycled (or its fence deleted). GL
|
||||||
|
/// objects can only be touched with our context current, so releases park here and
|
||||||
|
/// [`GlConverter::convert`] drains them.
|
||||||
|
struct Retired {
|
||||||
|
tex: u32,
|
||||||
|
sync: usize, // GLsync as usize — the release closure must be Send
|
||||||
|
size: (u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GlConverter {
|
||||||
|
ctx: gdk::GLContext,
|
||||||
|
egl: Egl,
|
||||||
|
egl_display: *mut c_void,
|
||||||
|
create_image: EglCreateImageKhr,
|
||||||
|
destroy_image: EglDestroyImageKhr,
|
||||||
|
gl: GlFns,
|
||||||
|
program: u32,
|
||||||
|
vao: u32,
|
||||||
|
fbo: u32,
|
||||||
|
u_mat: i32,
|
||||||
|
u_off: i32,
|
||||||
|
/// Uniforms match this signaling; a change (mid-stream SDR↔HDR) re-uploads them.
|
||||||
|
uniforms_for: Option<ColorDesc>,
|
||||||
|
/// Free output textures + fences returned by GTK's release funcs (shared with the
|
||||||
|
/// `Send` release closures; drained/recycled at each convert).
|
||||||
|
retired: Arc<Mutex<Vec<Retired>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlConverter {
|
||||||
|
/// Build against the widget's display. Must run on the GTK main thread; fails cleanly
|
||||||
|
/// on a GLX-backed GDK context or missing EGL dmabuf-import extensions (the caller
|
||||||
|
/// falls back to software decode).
|
||||||
|
pub fn new(widget: &impl IsA<gtk::Widget>) -> Result<GlConverter> {
|
||||||
|
let display = widget.display();
|
||||||
|
let ctx = display.create_gl_context().context("create GdkGLContext")?;
|
||||||
|
ctx.realize().context("realize GdkGLContext")?;
|
||||||
|
ctx.make_current();
|
||||||
|
|
||||||
|
// SAFETY (whole block): the GdkGLContext is current on this thread, so EGL/GL
|
||||||
|
// queries and object creation target it; pointers are only used while it lives.
|
||||||
|
unsafe {
|
||||||
|
let egl = Egl::load_required().context("dlopen libEGL")?;
|
||||||
|
let egl_display = egl
|
||||||
|
.get_current_display()
|
||||||
|
.ok_or_else(|| anyhow!("GDK context is not EGL-backed (GLX?)"))?;
|
||||||
|
let exts = egl
|
||||||
|
.query_string(Some(egl_display), egl::EXTENSIONS)
|
||||||
|
.context("EGL_EXTENSIONS")?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
for need in ["EGL_EXT_image_dma_buf_import", "EGL_KHR_image_base"] {
|
||||||
|
if !exts.contains(need) {
|
||||||
|
bail!("EGL lacks {need}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tiled surfaces carry an explicit modifier — without the _modifiers extension
|
||||||
|
// the import would silently assume implied/linear and sample garbage.
|
||||||
|
if !exts.contains("EGL_EXT_image_dma_buf_import_modifiers") {
|
||||||
|
bail!("EGL lacks EGL_EXT_image_dma_buf_import_modifiers");
|
||||||
|
}
|
||||||
|
let create_image: EglCreateImageKhr =
|
||||||
|
std::mem::transmute::<extern "system" fn(), EglCreateImageKhr>(
|
||||||
|
egl.get_proc_address("eglCreateImageKHR")
|
||||||
|
.ok_or_else(|| anyhow!("no eglCreateImageKHR"))?,
|
||||||
|
);
|
||||||
|
let destroy_image: EglDestroyImageKhr =
|
||||||
|
std::mem::transmute::<extern "system" fn(), EglDestroyImageKhr>(
|
||||||
|
egl.get_proc_address("eglDestroyImageKHR")
|
||||||
|
.ok_or_else(|| anyhow!("no eglDestroyImageKHR"))?,
|
||||||
|
);
|
||||||
|
let gl = GlFns::load(&egl)?;
|
||||||
|
|
||||||
|
let es = ctx.api().contains(gdk::GLAPI::GLES);
|
||||||
|
let program = build_program(&gl, es)?;
|
||||||
|
(gl.UseProgram)(program);
|
||||||
|
let u_mat = (gl.GetUniformLocation)(program, c"u_mat".as_ptr() as *const u8);
|
||||||
|
let u_off = (gl.GetUniformLocation)(program, c"u_off".as_ptr() as *const u8);
|
||||||
|
let u_y = (gl.GetUniformLocation)(program, c"u_y".as_ptr() as *const u8);
|
||||||
|
let u_c = (gl.GetUniformLocation)(program, c"u_c".as_ptr() as *const u8);
|
||||||
|
(gl.Uniform1i)(u_y, 0);
|
||||||
|
(gl.Uniform1i)(u_c, 1);
|
||||||
|
let mut vao = 0u32;
|
||||||
|
(gl.GenVertexArrays)(1, &mut vao);
|
||||||
|
let mut fbo = 0u32;
|
||||||
|
(gl.GenFramebuffers)(1, &mut fbo);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
gles = es,
|
||||||
|
"GL presenter ready — VAAPI dmabufs convert in-process (own EGL import + shader)"
|
||||||
|
);
|
||||||
|
Ok(GlConverter {
|
||||||
|
ctx,
|
||||||
|
egl,
|
||||||
|
egl_display: egl_display.as_ptr(),
|
||||||
|
create_image,
|
||||||
|
destroy_image,
|
||||||
|
gl,
|
||||||
|
program,
|
||||||
|
vao,
|
||||||
|
fbo,
|
||||||
|
u_mat,
|
||||||
|
u_off,
|
||||||
|
uniforms_for: None,
|
||||||
|
retired: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert one decoded frame into an RGBA `GdkTexture`. The source surface (guard) is
|
||||||
|
/// held until GTK releases the output texture — the GPU read is long finished by then.
|
||||||
|
/// `color_state` tags the output (full-range RGB, transfer left baked — same semantics
|
||||||
|
/// as the software path's tagged `GdkMemoryTexture`); `None` = untagged sRGB.
|
||||||
|
pub fn convert(
|
||||||
|
&mut self,
|
||||||
|
frame: DmabufFrame,
|
||||||
|
color_state: Option<&gdk::ColorState>,
|
||||||
|
) -> Result<gdk::Texture> {
|
||||||
|
if frame.fourcc != DRM_FORMAT_NV12 {
|
||||||
|
bail!("GL presenter handles NV12 only (got {:#x})", frame.fourcc);
|
||||||
|
}
|
||||||
|
if frame.planes.len() < 2 {
|
||||||
|
bail!("NV12 needs 2 planes (got {})", frame.planes.len());
|
||||||
|
}
|
||||||
|
self.ctx.make_current();
|
||||||
|
let gl = &self.gl;
|
||||||
|
|
||||||
|
// SAFETY (whole body): our context is current; every GL/EGL object created here is
|
||||||
|
// either destroyed before return or owned by the pool/release machinery.
|
||||||
|
unsafe {
|
||||||
|
// Recycle what GTK released since last frame (GL objects need the context, so
|
||||||
|
// the release closures only park entries — this is where they die/revive).
|
||||||
|
let size = (frame.width, frame.height);
|
||||||
|
let mut out_tex = 0u32;
|
||||||
|
{
|
||||||
|
let mut retired = self.retired.lock().unwrap();
|
||||||
|
retired.retain_mut(|r| {
|
||||||
|
if r.sync != 0 {
|
||||||
|
(gl.DeleteSync)(r.sync as *const c_void);
|
||||||
|
r.sync = 0;
|
||||||
|
}
|
||||||
|
if out_tex == 0 && r.size == size {
|
||||||
|
out_tex = r.tex;
|
||||||
|
false
|
||||||
|
} else if r.size != size {
|
||||||
|
(gl.DeleteTextures)(1, &r.tex); // stale size (mode change)
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true // spare same-size texture for a later frame
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if out_tex == 0 {
|
||||||
|
(gl.GenTextures)(1, &mut out_tex);
|
||||||
|
(gl.BindTexture)(GL_TEXTURE_2D, out_tex);
|
||||||
|
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||||
|
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||||
|
(gl.TexImage2D)(
|
||||||
|
GL_TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
GL_RGBA8 as i32,
|
||||||
|
frame.width as i32,
|
||||||
|
frame.height as i32,
|
||||||
|
0,
|
||||||
|
GL_RGBA,
|
||||||
|
GL_UNSIGNED_BYTE,
|
||||||
|
std::ptr::null(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import both planes with the surface's modifier — exactly the layer-wise
|
||||||
|
// import Moonlight/mpv drive on this hardware.
|
||||||
|
let y = &frame.planes[0];
|
||||||
|
let c = &frame.planes[1];
|
||||||
|
let img_y =
|
||||||
|
self.plane_image(frame.width, frame.height, DRM_FORMAT_R8, y, frame.modifier)?;
|
||||||
|
let img_c = match self.plane_image(
|
||||||
|
frame.width.div_ceil(2),
|
||||||
|
frame.height.div_ceil(2),
|
||||||
|
DRM_FORMAT_GR88,
|
||||||
|
c,
|
||||||
|
frame.modifier,
|
||||||
|
) {
|
||||||
|
Ok(img) => img,
|
||||||
|
Err(e) => {
|
||||||
|
(self.destroy_image)(self.egl_display, img_y);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut planes = [0u32; 2];
|
||||||
|
(gl.GenTextures)(2, planes.as_mut_ptr());
|
||||||
|
for (tex, img) in planes.iter().zip([img_y, img_c]) {
|
||||||
|
(gl.BindTexture)(GL_TEXTURE_2D, *tex);
|
||||||
|
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||||
|
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||||
|
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||||
|
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||||
|
(gl.EGLImageTargetTexture2DOES)(GL_TEXTURE_2D, img);
|
||||||
|
}
|
||||||
|
|
||||||
|
(gl.UseProgram)(self.program);
|
||||||
|
if self.uniforms_for != Some(frame.color) {
|
||||||
|
let (mat, off) = yuv_to_rgb(frame.color);
|
||||||
|
(gl.UniformMatrix3fv)(self.u_mat, 1, 0, mat.as_ptr());
|
||||||
|
(gl.Uniform3fv)(self.u_off, 1, off.as_ptr());
|
||||||
|
self.uniforms_for = Some(frame.color);
|
||||||
|
}
|
||||||
|
(gl.BindFramebuffer)(GL_FRAMEBUFFER, self.fbo);
|
||||||
|
(gl.FramebufferTexture2D)(
|
||||||
|
GL_FRAMEBUFFER,
|
||||||
|
GL_COLOR_ATTACHMENT0,
|
||||||
|
GL_TEXTURE_2D,
|
||||||
|
out_tex,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let status = (gl.CheckFramebufferStatus)(GL_FRAMEBUFFER);
|
||||||
|
if status != GL_FRAMEBUFFER_COMPLETE {
|
||||||
|
(gl.BindFramebuffer)(GL_FRAMEBUFFER, 0);
|
||||||
|
(gl.DeleteTextures)(2, planes.as_ptr());
|
||||||
|
(self.destroy_image)(self.egl_display, img_y);
|
||||||
|
(self.destroy_image)(self.egl_display, img_c);
|
||||||
|
(gl.DeleteTextures)(1, &out_tex);
|
||||||
|
bail!("FBO incomplete ({status:#x})");
|
||||||
|
}
|
||||||
|
(gl.Viewport)(0, 0, frame.width as i32, frame.height as i32);
|
||||||
|
(gl.BindVertexArray)(self.vao);
|
||||||
|
(gl.ActiveTexture)(GL_TEXTURE0);
|
||||||
|
(gl.BindTexture)(GL_TEXTURE_2D, planes[0]);
|
||||||
|
(gl.ActiveTexture)(GL_TEXTURE0 + 1);
|
||||||
|
(gl.BindTexture)(GL_TEXTURE_2D, planes[1]);
|
||||||
|
(gl.DrawArrays)(GL_TRIANGLES, 0, 3);
|
||||||
|
(gl.BindFramebuffer)(GL_FRAMEBUFFER, 0);
|
||||||
|
|
||||||
|
let sync = (gl.FenceSync)(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||||
|
(gl.Flush)();
|
||||||
|
// The draw is queued: plane textures + images can go now (the driver keeps the
|
||||||
|
// underlying buffers alive until the queued commands execute).
|
||||||
|
(gl.DeleteTextures)(2, planes.as_ptr());
|
||||||
|
(self.destroy_image)(self.egl_display, img_y);
|
||||||
|
(self.destroy_image)(self.egl_display, img_c);
|
||||||
|
|
||||||
|
let err = (gl.GetError)();
|
||||||
|
if err != 0 {
|
||||||
|
(gl.DeleteTextures)(1, &out_tex);
|
||||||
|
bail!("GL error {err:#x} during convert");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut b = gdk::GLTextureBuilder::new()
|
||||||
|
.set_context(Some(&self.ctx))
|
||||||
|
.set_id(out_tex)
|
||||||
|
.set_width(frame.width as i32)
|
||||||
|
.set_height(frame.height as i32)
|
||||||
|
.set_format(gdk::MemoryFormat::R8g8b8a8)
|
||||||
|
.set_sync(Some(sync));
|
||||||
|
if let Some(state) = color_state {
|
||||||
|
b = b.set_color_state(state);
|
||||||
|
}
|
||||||
|
let retired = self.retired.clone();
|
||||||
|
let guard = frame.guard;
|
||||||
|
let sync_bits = sync as usize; // GLsync as usize — the closure must be Send
|
||||||
|
let texture = b.build_with_release_func(move || {
|
||||||
|
drop(guard); // the decoder surface outlived every GPU read of it
|
||||||
|
retired.lock().unwrap().push(Retired {
|
||||||
|
tex: out_tex,
|
||||||
|
sync: sync_bits,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Ok(texture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One single-plane `EGLImage` over a dmabuf plane (R8 luma / GR88 chroma), modifier
|
||||||
|
/// passed explicitly.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `self.ctx` must be current; the fd stays owned by the caller (EGL dups internally).
|
||||||
|
unsafe fn plane_image(
|
||||||
|
&self,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fourcc: u32,
|
||||||
|
plane: &crate::video::DmabufPlane,
|
||||||
|
modifier: u64,
|
||||||
|
) -> Result<*const c_void> {
|
||||||
|
let mut attribs = vec![
|
||||||
|
EGL_WIDTH,
|
||||||
|
width as usize,
|
||||||
|
EGL_HEIGHT,
|
||||||
|
height as usize,
|
||||||
|
EGL_LINUX_DRM_FOURCC_EXT,
|
||||||
|
fourcc as usize,
|
||||||
|
EGL_DMA_BUF_PLANE0_FD_EXT,
|
||||||
|
plane.fd as usize,
|
||||||
|
EGL_DMA_BUF_PLANE0_OFFSET_EXT,
|
||||||
|
plane.offset as usize,
|
||||||
|
EGL_DMA_BUF_PLANE0_PITCH_EXT,
|
||||||
|
plane.stride as usize,
|
||||||
|
];
|
||||||
|
if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 {
|
||||||
|
attribs.extend_from_slice(&[
|
||||||
|
EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT,
|
||||||
|
(modifier & 0xffff_ffff) as usize,
|
||||||
|
EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT,
|
||||||
|
(modifier >> 32) as usize,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
attribs.push(EGL_NONE);
|
||||||
|
// SAFETY: attribs is a valid EGL_NONE-terminated list; display/context are live.
|
||||||
|
let img = unsafe {
|
||||||
|
(self.create_image)(
|
||||||
|
self.egl_display,
|
||||||
|
std::ptr::null_mut(), // EGL_NO_CONTEXT — dmabuf import
|
||||||
|
EGL_LINUX_DMA_BUF_EXT,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
attribs.as_ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if img.is_null() {
|
||||||
|
bail!(
|
||||||
|
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:#x}",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fourcc,
|
||||||
|
modifier,
|
||||||
|
self.egl.get_error().map(|e| e as u32).unwrap_or(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for GlConverter {
|
||||||
|
/// Delete our objects from the shared context group (the context lives in GDK's share
|
||||||
|
/// group — per-session leftovers would pile up across sessions). Textures GTK still
|
||||||
|
/// holds at this moment release into `retired` afterwards, where nobody drains them:
|
||||||
|
/// those names leak, but it's ≤ the pool depth once per session, not per frame.
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.ctx.make_current();
|
||||||
|
let gl = &self.gl;
|
||||||
|
// SAFETY: context current; only objects this converter created are deleted.
|
||||||
|
unsafe {
|
||||||
|
for r in self.retired.lock().unwrap().drain(..) {
|
||||||
|
if r.sync != 0 {
|
||||||
|
(gl.DeleteSync)(r.sync as *const c_void);
|
||||||
|
}
|
||||||
|
(gl.DeleteTextures)(1, &r.tex);
|
||||||
|
}
|
||||||
|
(gl.DeleteFramebuffers)(1, &self.fbo);
|
||||||
|
(gl.DeleteVertexArrays)(1, &self.vao);
|
||||||
|
(gl.DeleteProgram)(self.program);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile the fullscreen-triangle NV12→RGB program (GLSL 300 es / 330 core per the GDK
|
||||||
|
/// context's API). `gl_VertexID` drives the geometry — no buffers at all.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// A GL context must be current; `gl` must belong to it.
|
||||||
|
unsafe fn build_program(gl: &GlFns, es: bool) -> Result<u32> {
|
||||||
|
let header = if es {
|
||||||
|
"#version 300 es\nprecision highp float;\n"
|
||||||
|
} else {
|
||||||
|
"#version 330 core\n"
|
||||||
|
};
|
||||||
|
let vs_src = format!(
|
||||||
|
"{header}
|
||||||
|
out vec2 v_uv;
|
||||||
|
void main() {{
|
||||||
|
vec2 p = vec2(float((gl_VertexID & 1) << 2) - 1.0, float((gl_VertexID & 2) << 1) - 1.0);
|
||||||
|
v_uv = p * 0.5 + 0.5;
|
||||||
|
gl_Position = vec4(p, 0.0, 1.0);
|
||||||
|
}}"
|
||||||
|
);
|
||||||
|
let fs_src = format!(
|
||||||
|
"{header}
|
||||||
|
in vec2 v_uv;
|
||||||
|
out vec4 frag;
|
||||||
|
uniform sampler2D u_y;
|
||||||
|
uniform sampler2D u_c;
|
||||||
|
uniform mat3 u_mat;
|
||||||
|
uniform vec3 u_off;
|
||||||
|
void main() {{
|
||||||
|
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg);
|
||||||
|
frag = vec4(clamp(u_mat * (yuv + u_off), 0.0, 1.0), 1.0);
|
||||||
|
}}"
|
||||||
|
);
|
||||||
|
// SAFETY: caller holds a current context; sources are valid UTF-8 with explicit lengths.
|
||||||
|
unsafe {
|
||||||
|
let compile = |kind: u32, src: &str| -> Result<u32> {
|
||||||
|
let sh = (gl.CreateShader)(kind);
|
||||||
|
let ptr = src.as_ptr();
|
||||||
|
let len = src.len() as i32;
|
||||||
|
(gl.ShaderSource)(sh, 1, &ptr, &len);
|
||||||
|
(gl.CompileShader)(sh);
|
||||||
|
let mut ok = 0i32;
|
||||||
|
(gl.GetShaderiv)(sh, GL_COMPILE_STATUS, &mut ok);
|
||||||
|
if ok == 0 {
|
||||||
|
let mut log = vec![0u8; 1024];
|
||||||
|
let mut n = 0i32;
|
||||||
|
(gl.GetShaderInfoLog)(sh, 1024, &mut n, log.as_mut_ptr());
|
||||||
|
(gl.DeleteShader)(sh);
|
||||||
|
bail!(
|
||||||
|
"shader compile: {}",
|
||||||
|
String::from_utf8_lossy(&log[..n.max(0) as usize])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(sh)
|
||||||
|
};
|
||||||
|
let vs = compile(GL_VERTEX_SHADER, &vs_src)?;
|
||||||
|
let fs = match compile(GL_FRAGMENT_SHADER, &fs_src) {
|
||||||
|
Ok(fs) => fs,
|
||||||
|
Err(e) => {
|
||||||
|
(gl.DeleteShader)(vs);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let prog = (gl.CreateProgram)();
|
||||||
|
(gl.AttachShader)(prog, vs);
|
||||||
|
(gl.AttachShader)(prog, fs);
|
||||||
|
(gl.LinkProgram)(prog);
|
||||||
|
(gl.DeleteShader)(vs);
|
||||||
|
(gl.DeleteShader)(fs);
|
||||||
|
let mut ok = 0i32;
|
||||||
|
(gl.GetProgramiv)(prog, GL_LINK_STATUS, &mut ok);
|
||||||
|
if ok == 0 {
|
||||||
|
bail!("program link failed");
|
||||||
|
}
|
||||||
|
Ok(prog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix,
|
||||||
|
full_range,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(mat: &[f32; 9], off: &[f32; 3], yuv: [f32; 3]) -> [f32; 3] {
|
||||||
|
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
||||||
|
// Column-major: out[r] = Σ mat[col*3 + r] * v[col]
|
||||||
|
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0.
|
||||||
|
#[test]
|
||||||
|
fn bt709_limited_white_black() {
|
||||||
|
let (mat, off) = yuv_to_rgb(desc(1, false));
|
||||||
|
let white = apply(&mat, &off, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||||
|
let black = apply(&mat, &off, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||||
|
for (w, b) in white.iter().zip(black) {
|
||||||
|
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
||||||
|
assert!(b.abs() < 0.005, "black {black:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-range identity points: Y=1 → white, Y=0 → black, and a 601-vs-709 red spot
|
||||||
|
/// check (pure V excursion produces R = 2(1−Kr)·0.5).
|
||||||
|
#[test]
|
||||||
|
fn full_range_and_red_excursion() {
|
||||||
|
let (mat, off) = yuv_to_rgb(desc(5, true));
|
||||||
|
let white = apply(&mat, &off, [1.0, 0.5, 0.5]);
|
||||||
|
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
||||||
|
let red = apply(&mat, &off, [0.0, 0.5, 1.0]);
|
||||||
|
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
||||||
|
// 709 differs from 601 in the same spot — guards the matrix-code dispatch.
|
||||||
|
let (mat709, off709) = yuv_to_rgb(desc(1, true));
|
||||||
|
let red709 = apply(&mat709, &off709, [0.0, 0.5, 1.0]);
|
||||||
|
assert!(
|
||||||
|
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
||||||
|
"{red709:?}"
|
||||||
|
);
|
||||||
|
assert!((red[0] - red709[0]).abs() > 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//! Client-side Wake-on-LAN: parse stored MAC strings and hand them to the shared core sender
|
||||||
|
//! (`punktfunk_core::wol`). A sleeping host has no ARP entry, so the broadcast the core sends is
|
||||||
|
//! what actually wakes it; this is called just before connecting to an offline saved host, and
|
||||||
|
//! from the explicit "Wake host" menu item / `--wake` CLI mode.
|
||||||
|
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
|
/// Fire a Wake-on-LAN magic packet at `macs` (each `aa:bb:cc:dd:ee:ff`), also unicasting
|
||||||
|
/// `last_ip` when given. Best-effort — logs the outcome and never blocks the caller meaningfully
|
||||||
|
/// (the core sends a short burst of datagrams and returns).
|
||||||
|
pub fn wake(macs: &[String], last_ip: Option<Ipv4Addr>) {
|
||||||
|
let parsed: Vec<[u8; 6]> = macs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| punktfunk_core::wol::parse_mac(s))
|
||||||
|
.collect();
|
||||||
|
if parsed.is_empty() {
|
||||||
|
tracing::warn!("wake requested but no valid MAC is known for this host");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match punktfunk_core::wol::send_magic_packet(&parsed, last_ip) {
|
||||||
|
Ok(()) => tracing::info!(count = parsed.len(), "sent Wake-on-LAN magic packet"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "Wake-on-LAN send failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -245,6 +245,7 @@ fn connect_with(
|
|||||||
port: target.port,
|
port: target.port,
|
||||||
fp_hex: trust::hex(&fingerprint),
|
fp_hex: trust::hex(&fingerprint),
|
||||||
paired: persist_paired,
|
paired: persist_paired,
|
||||||
|
mac: target.mac.clone(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use windows_reactor::*;
|
|||||||
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
||||||
const MENU_CONNECT: &str = "Connect";
|
const MENU_CONNECT: &str = "Connect";
|
||||||
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
||||||
|
const MENU_WAKE: &str = "Wake host";
|
||||||
const MENU_RENAME: &str = "Rename\u{2026}";
|
const MENU_RENAME: &str = "Rename\u{2026}";
|
||||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||||
|
|
||||||
@@ -318,10 +319,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
port: k.port,
|
port: k.port,
|
||||||
fp_hex: Some(k.fp_hex.clone()),
|
fp_hex: Some(k.fp_hex.clone()),
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
|
mac: k.mac.clone(),
|
||||||
};
|
};
|
||||||
let online = hosts
|
let online = hosts
|
||||||
.iter()
|
.iter()
|
||||||
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port));
|
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port));
|
||||||
|
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
|
||||||
|
// it once it sleeps (no-op / no disk write when unchanged).
|
||||||
|
if let Some(a) = hosts.iter().find(|h| {
|
||||||
|
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||||
|
&& !h.mac.is_empty()
|
||||||
|
}) {
|
||||||
|
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||||
|
}
|
||||||
|
let can_wake = !online && !k.mac.is_empty();
|
||||||
let menu = {
|
let menu = {
|
||||||
let (svc, target) = (props.svc.clone(), target.clone());
|
let (svc, target) = (props.svc.clone(), target.clone());
|
||||||
let (sf, sr) = (set_forget.clone(), set_rename.clone());
|
let (sf, sr) = (set_forget.clone(), set_rename.clone());
|
||||||
@@ -331,17 +342,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
.subtle()
|
.subtle()
|
||||||
.tooltip("More options")
|
.tooltip("More options")
|
||||||
.automation_name("More options")
|
.automation_name("More options")
|
||||||
.menu_flyout(vec![
|
.menu_flyout({
|
||||||
menu_item(MENU_CONNECT),
|
let mut items = vec![menu_item(MENU_CONNECT), menu_item(MENU_SPEED)];
|
||||||
menu_item(MENU_SPEED),
|
// Offer an explicit wake only when the host is offline and we have a MAC.
|
||||||
menu_item(MENU_RENAME),
|
if can_wake {
|
||||||
menu_separator(),
|
items.push(menu_item(MENU_WAKE));
|
||||||
menu_item(MENU_FORGET),
|
}
|
||||||
])
|
items.push(menu_item(MENU_RENAME));
|
||||||
|
items.push(menu_separator());
|
||||||
|
items.push(menu_item(MENU_FORGET));
|
||||||
|
items
|
||||||
|
})
|
||||||
.on_item_clicked(move |item: String| match item.as_str() {
|
.on_item_clicked(move |item: String| match item.as_str() {
|
||||||
MENU_CONNECT => {
|
MENU_CONNECT => {
|
||||||
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
||||||
}
|
}
|
||||||
|
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
||||||
MENU_SPEED => {
|
MENU_SPEED => {
|
||||||
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
// New run: invalidate any still-in-flight probe, reset the screen.
|
// New run: invalidate any still-in-flight probe, reset the screen.
|
||||||
@@ -369,7 +385,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
if k.paired { Pill::Good } else { Pill::Info },
|
if k.paired { Pill::Good } else { Pill::Info },
|
||||||
),
|
),
|
||||||
Some(menu),
|
Some(menu),
|
||||||
Some(Box::new(move || initiate(&ctx2, target.clone(), &ss, &st))),
|
Some(Box::new(move || {
|
||||||
|
// Auto-wake an offline saved host before connecting; the connect's own
|
||||||
|
// retry/timeout gives a woken host time to come up.
|
||||||
|
if can_wake {
|
||||||
|
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||||
|
}
|
||||||
|
initiate(&ctx2, target.clone(), &ss, &st)
|
||||||
|
})),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
body.push(tile_grid(tiles, cols));
|
body.push(tile_grid(tiles, cols));
|
||||||
@@ -406,6 +429,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
port: h.port,
|
port: h.port,
|
||||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||||
pair_optional: h.pair == "optional",
|
pair_optional: h.pair == "optional",
|
||||||
|
mac: h.mac.clone(),
|
||||||
};
|
};
|
||||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||||
let (badge, kind) = if h.pair == "required" {
|
let (badge, kind) = if h.pair == "required" {
|
||||||
@@ -486,6 +510,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
port,
|
port,
|
||||||
fp_hex: None,
|
fp_hex: None,
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
|
mac: Vec::new(),
|
||||||
},
|
},
|
||||||
&ss,
|
&ss,
|
||||||
&st,
|
&st,
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ pub(crate) struct Target {
|
|||||||
pub(crate) port: u16,
|
pub(crate) port: u16,
|
||||||
pub(crate) fp_hex: Option<String>,
|
pub(crate) fp_hex: Option<String>,
|
||||||
pub(crate) pair_optional: bool,
|
pub(crate) pair_optional: bool,
|
||||||
|
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
|
||||||
|
/// magic packet before connecting to an offline host. Empty when none is known.
|
||||||
|
pub(crate) mac: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable app services handed to the page components as props. Each routed screen that uses
|
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
port: target3.port,
|
port: target3.port,
|
||||||
fp_hex: trust::hex(&fp),
|
fp_hex: trust::hex(&fp),
|
||||||
paired: true,
|
paired: true,
|
||||||
|
mac: target3.mac.clone(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ pub struct DiscoveredHost {
|
|||||||
pub fp_hex: String,
|
pub fp_hex: String,
|
||||||
/// Pairing requirement: `"required"` or `"optional"`.
|
/// Pairing requirement: `"required"` or `"optional"`.
|
||||||
pub pair: String,
|
pub pair: String,
|
||||||
|
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the
|
||||||
|
/// hosts page persists onto the matching saved host so it can wake it later. Empty if absent.
|
||||||
|
pub mac: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||||
@@ -63,6 +66,11 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
|||||||
port: info.get_port(),
|
port: info.get_port(),
|
||||||
fp_hex: val("fp"),
|
fp_hex: val("fp"),
|
||||||
pair: val("pair"),
|
pair: val("pair"),
|
||||||
|
mac: val("mac")
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect(),
|
||||||
};
|
};
|
||||||
if tx.send_blocking(host).is_err() {
|
if tx.send_blocking(host).is_err() {
|
||||||
break; // UI gone — stop browsing
|
break; // UI gone — stop browsing
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ mod trust;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod wol;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn main() {
|
fn main() {
|
||||||
// With #![windows_subsystem = "windows"] the process starts with no console, so the GUI/MSIX
|
// With #![windows_subsystem = "windows"] the process starts with no console, so the GUI/MSIX
|
||||||
@@ -187,6 +190,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
|||||||
port,
|
port,
|
||||||
fp_hex: trust::hex(&fp),
|
fp_hex: trust::hex(&fp),
|
||||||
paired: true,
|
paired: true,
|
||||||
|
mac: Vec::new(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
tracing::info!(fp = %trust::hex(&fp), "paired");
|
tracing::info!(fp = %trust::hex(&fp), "paired");
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ pub struct KnownHost {
|
|||||||
pub fp_hex: String,
|
pub fp_hex: String,
|
||||||
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
||||||
pub paired: bool,
|
pub paired: bool,
|
||||||
|
/// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was
|
||||||
|
/// online, so we can wake it once it sleeps. `default` so pre-existing stores load; empty until
|
||||||
|
/// first learned.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mac: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
@@ -106,12 +111,38 @@ impl KnownHosts {
|
|||||||
h.addr = entry.addr;
|
h.addr = entry.addr;
|
||||||
h.port = entry.port;
|
h.port = entry.port;
|
||||||
h.paired |= entry.paired;
|
h.paired |= entry.paired;
|
||||||
|
// A trust-decision upsert (which carries no MAC) must not wipe learned MACs.
|
||||||
|
if !entry.mac.is_empty() {
|
||||||
|
h.mac = entry.mac;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.hosts.push(entry);
|
self.hosts.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host is
|
||||||
|
/// online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so the
|
||||||
|
/// hosts page can call it on every discovery tick without churning the store.
|
||||||
|
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||||
|
if mac.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
let Some(h) = known
|
||||||
|
.hosts
|
||||||
|
.iter_mut()
|
||||||
|
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if h.mac == mac {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
h.mac = mac.to_vec();
|
||||||
|
let _ = known.save();
|
||||||
|
}
|
||||||
|
|
||||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//! Client-side Wake-on-LAN: parse stored MAC strings and hand them to the shared core sender
|
||||||
|
//! (`punktfunk_core::wol`). A sleeping host has no ARP entry, so the broadcast the core sends is
|
||||||
|
//! what actually wakes it; this is called just before connecting to an offline saved host and
|
||||||
|
//! from the explicit "Wake host" menu item.
|
||||||
|
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
|
/// Fire a Wake-on-LAN magic packet at `macs` (each `aa:bb:cc:dd:ee:ff`), also unicasting
|
||||||
|
/// `last_ip` when given. Best-effort — logs the outcome and returns promptly (the core sends a
|
||||||
|
/// short burst of datagrams).
|
||||||
|
pub fn wake(macs: &[String], last_ip: Option<Ipv4Addr>) {
|
||||||
|
let parsed: Vec<[u8; 6]> = macs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| punktfunk_core::wol::parse_mac(s))
|
||||||
|
.collect();
|
||||||
|
if parsed.is_empty() {
|
||||||
|
tracing::warn!("wake requested but no valid MAC is known for this host");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match punktfunk_core::wol::send_magic_packet(&parsed, last_ip) {
|
||||||
|
Ok(()) => tracing::info!(count = parsed.len(), "sent Wake-on-LAN magic packet"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "Wake-on-LAN send failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ thiserror = "2"
|
|||||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
|
# Interface enumeration for Wake-on-LAN: computes each NIC's subnet-directed broadcast so a
|
||||||
|
# magic packet reaches the host's L2 segment on multi-homed clients (VPN/docker/multiple LANs),
|
||||||
|
# not just the default route. Tiny, cross-platform (getifaddrs / GetAdaptersAddresses), no cmake.
|
||||||
|
if-addrs = "0.13"
|
||||||
|
|
||||||
quinn = { version = "0.11", optional = true }
|
quinn = { version = "0.11", optional = true }
|
||||||
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
|
||||||
|
|||||||
@@ -183,6 +183,60 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
|
|||||||
crate::ABI_VERSION
|
crate::ABI_VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s).
|
||||||
|
///
|
||||||
|
/// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) —
|
||||||
|
/// a host may report several NICs; all are woken. `last_known_ip`, if non-NULL, is an IPv4
|
||||||
|
/// dotted-quad string additionally targeted by unicast (pass NULL to skip). The packet is
|
||||||
|
/// broadcast to every local interface's subnet-directed broadcast and to `255.255.255.255` on
|
||||||
|
/// ports 9 and 7. This does NOT require an open connection and is not part of the QUIC surface.
|
||||||
|
///
|
||||||
|
/// Returns `Ok` if at least one datagram was sent. Call off the UI thread.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL,
|
||||||
|
/// must be a NUL-terminated string.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_wake_on_lan(
|
||||||
|
macs: *const u8,
|
||||||
|
mac_count: usize,
|
||||||
|
last_known_ip: *const c_char,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
if macs.is_null() {
|
||||||
|
return PunktfunkStatus::NullPointer;
|
||||||
|
}
|
||||||
|
if mac_count == 0 {
|
||||||
|
return PunktfunkStatus::InvalidArg;
|
||||||
|
}
|
||||||
|
let bytes = unsafe { std::slice::from_raw_parts(macs, mac_count * 6) };
|
||||||
|
let mac_vec: Vec<crate::wol::Mac> = bytes
|
||||||
|
.chunks_exact(6)
|
||||||
|
.map(|c| {
|
||||||
|
let mut m = [0u8; 6];
|
||||||
|
m.copy_from_slice(c);
|
||||||
|
m
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let ip = if last_known_ip.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match unsafe { CStr::from_ptr(last_known_ip) }
|
||||||
|
.to_str()
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<std::net::Ipv4Addr>().ok())
|
||||||
|
{
|
||||||
|
Some(ip) => Some(ip),
|
||||||
|
None => return PunktfunkStatus::InvalidArg,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match crate::wol::send_magic_packet(&mac_vec, ip) {
|
||||||
|
Ok(()) => PunktfunkStatus::Ok,
|
||||||
|
Err(_) => PunktfunkStatus::Io,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
|
/// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
|
||||||
/// Returns NULL on error.
|
/// Returns NULL on error.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub mod quic;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
pub mod wol;
|
||||||
|
|
||||||
pub use config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
|
pub use config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
|
||||||
pub use error::{PunktfunkError, PunktfunkStatus, Result};
|
pub use error::{PunktfunkError, PunktfunkStatus, Result};
|
||||||
@@ -50,4 +51,6 @@ pub use stats::Stats;
|
|||||||
///
|
///
|
||||||
/// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
/// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
||||||
/// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
/// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
||||||
pub const ABI_VERSION: u32 = 2;
|
/// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
|
||||||
|
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
|
||||||
|
pub const ABI_VERSION: u32 = 3;
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! Wake-on-LAN: magic-packet builder + broadcast sender.
|
||||||
|
//!
|
||||||
|
//! Runtime-free by design — a magic packet is one fire-and-forget UDP datagram, so this needs
|
||||||
|
//! neither the `quic` feature nor an async runtime and links into every client (including the
|
||||||
|
//! QUIC-less builds). The Rust clients (linux/windows/android) call these `pub fn`s directly;
|
||||||
|
//! Swift/iOS reach them through the `punktfunk_wake_on_lan` C-ABI wrapper in [`crate::abi`].
|
||||||
|
//!
|
||||||
|
//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast
|
||||||
|
//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each
|
||||||
|
//! known host MAC we send the 102-byte packet to:
|
||||||
|
//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's
|
||||||
|
//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and
|
||||||
|
//! * the **limited broadcast** `255.255.255.255`, and
|
||||||
|
//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the
|
||||||
|
//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast),
|
||||||
|
//!
|
||||||
|
//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
||||||
|
|
||||||
|
/// A MAC address (EUI-48), the 6 bytes a magic packet targets.
|
||||||
|
pub type Mac = [u8; 6];
|
||||||
|
|
||||||
|
/// Conventional Wake-on-LAN UDP ports. 9 (discard) is by far the most common; 7 (echo) is a
|
||||||
|
/// historical alternative some NICs also listen on. Sending to both is free insurance.
|
||||||
|
const WOL_PORTS: [u16; 2] = [9, 7];
|
||||||
|
|
||||||
|
/// Times each packet is re-sent per call. UDP is lossy and this is fire-and-forget; a small
|
||||||
|
/// burst costs microseconds and materially improves the odds a waking NIC catches one. The
|
||||||
|
/// caller's connect-retry loop provides the longer-spaced re-attempts.
|
||||||
|
const BURST: usize = 3;
|
||||||
|
|
||||||
|
/// Parse a MAC string — `aa:bb:cc:dd:ee:ff` or `aa-bb-...`, case-insensitive — into 6 bytes.
|
||||||
|
/// Returns `None` for anything that isn't exactly six hex octets. Shared by the Rust clients
|
||||||
|
/// (linux/windows) so MAC parsing lives in one place; the Swift/Apple client parses its own.
|
||||||
|
pub fn parse_mac(s: &str) -> Option<Mac> {
|
||||||
|
let mut m = [0u8; 6];
|
||||||
|
let mut n = 0;
|
||||||
|
for part in s.split([':', '-']) {
|
||||||
|
if n == 6 {
|
||||||
|
return None; // too many octets
|
||||||
|
}
|
||||||
|
m[n] = u8::from_str_radix(part.trim(), 16).ok()?;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
(n == 6).then_some(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 102-byte magic packet for `mac`: 6×`0xFF` followed by the MAC repeated 16 times.
|
||||||
|
pub fn build_magic_packet(mac: Mac) -> [u8; 102] {
|
||||||
|
let mut pkt = [0xFFu8; 102];
|
||||||
|
for i in 0..16 {
|
||||||
|
let off = 6 + i * 6;
|
||||||
|
pkt[off..off + 6].copy_from_slice(&mac);
|
||||||
|
}
|
||||||
|
pkt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a wake for every MAC in `macs`. `last_known_ip`, when set, is additionally
|
||||||
|
/// targeted by unicast.
|
||||||
|
///
|
||||||
|
/// Returns `Ok` if at least one datagram was sent, so a single unreachable target (e.g. a
|
||||||
|
/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket
|
||||||
|
/// could be opened or nothing could be sent at all.
|
||||||
|
pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::Result<()> {
|
||||||
|
if macs.is_empty() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"no MAC addresses",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the target IP set: each interface's directed broadcast, the limited broadcast, and
|
||||||
|
// the optional last-known unicast. Dedup so a single-NIC client doesn't send twice.
|
||||||
|
let mut targets = broadcast_addrs();
|
||||||
|
targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255
|
||||||
|
if let Some(ip) = last_known_ip {
|
||||||
|
targets.push(ip);
|
||||||
|
}
|
||||||
|
targets.sort_unstable();
|
||||||
|
targets.dedup();
|
||||||
|
|
||||||
|
// One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the
|
||||||
|
// matching NIC via the routing table; the limited broadcast leaves via the default route.
|
||||||
|
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
|
||||||
|
sock.set_broadcast(true)?;
|
||||||
|
|
||||||
|
let mut sent_any = false;
|
||||||
|
for _ in 0..BURST {
|
||||||
|
for mac in macs {
|
||||||
|
let pkt = build_magic_packet(*mac);
|
||||||
|
for ip in &targets {
|
||||||
|
for port in WOL_PORTS {
|
||||||
|
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port));
|
||||||
|
if sock.send_to(&pkt, dst).is_ok() {
|
||||||
|
sent_any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sent_any {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other("no magic packet could be sent"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`,
|
||||||
|
/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing
|
||||||
|
/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires.
|
||||||
|
fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let ifaces = match if_addrs::get_if_addrs() {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(_) => return out,
|
||||||
|
};
|
||||||
|
for iface in ifaces {
|
||||||
|
if iface.is_loopback() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let if_addrs::IfAddr::V4(v4) = iface.addr {
|
||||||
|
let bcast = v4
|
||||||
|
.broadcast
|
||||||
|
.unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask)));
|
||||||
|
// Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we
|
||||||
|
// already add unconditionally.
|
||||||
|
if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST {
|
||||||
|
out.push(bcast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_packet_layout() {
|
||||||
|
let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
|
||||||
|
let pkt = build_magic_packet(mac);
|
||||||
|
assert_eq!(pkt.len(), 102);
|
||||||
|
// 6-byte 0xFF sync stream.
|
||||||
|
assert_eq!(&pkt[0..6], &[0xFF; 6]);
|
||||||
|
// MAC repeated exactly 16 times.
|
||||||
|
for i in 0..16 {
|
||||||
|
let off = 6 + i * 6;
|
||||||
|
assert_eq!(&pkt[off..off + 6], &mac, "repetition {i} mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_macs_is_error() {
|
||||||
|
assert!(send_magic_packet(&[], None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mac_forms() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_mac("aa:bb:cc:dd:ee:ff"),
|
||||||
|
Some([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_mac("AA-BB-CC-DD-EE-FF"),
|
||||||
|
Some([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])
|
||||||
|
);
|
||||||
|
assert_eq!(parse_mac("01:02:03:04:05:06"), Some([1, 2, 3, 4, 5, 6]));
|
||||||
|
assert_eq!(parse_mac("aa:bb:cc:dd:ee"), None); // too few
|
||||||
|
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff:00"), None); // too many
|
||||||
|
assert_eq!(parse_mac("zz:bb:cc:dd:ee:ff"), None); // non-hex
|
||||||
|
assert_eq!(parse_mac(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_does_not_panic_with_a_mac() {
|
||||||
|
// Best-effort: binds a real socket and broadcasts on the loopback host. Must not panic
|
||||||
|
// and, on any machine with a usable network stack, should report success.
|
||||||
|
let _ = send_magic_packet(&[[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]], None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broadcast_addrs_never_contains_limited_or_unspecified() {
|
||||||
|
for b in broadcast_addrs() {
|
||||||
|
assert_ne!(b, Ipv4Addr::BROADCAST);
|
||||||
|
assert!(!b.is_unspecified());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
tracing-log = "0.2"
|
tracing-log = "0.2"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
mdns-sd = "0.20"
|
mdns-sd = "0.20"
|
||||||
|
# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record.
|
||||||
|
# `mac_address` reads a NIC's hardware address; `if-addrs` maps the routed IP to its interface name.
|
||||||
|
mac_address = "1"
|
||||||
|
if-addrs = "0.13"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
rsa = "0.9"
|
rsa = "0.9"
|
||||||
sha2 = { version = "0.10", features = ["oid"] }
|
sha2 = { version = "0.10", features = ["oid"] }
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
|
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
|
||||||
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
|
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
|
||||||
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
|
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
|
||||||
|
//! - `mac` — the host's wake-capable NIC MAC(s) (comma-separated, routed NIC first), which a client
|
||||||
|
//! persists so it can Wake-on-LAN this host after it sleeps. Advisory/unauthenticated (a wrong
|
||||||
|
//! MAC only makes a wake fail). Omitted when none can be read.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||||
@@ -63,6 +66,18 @@ pub fn advertise_native(
|
|||||||
if let Some(mgmt) = mgmt_port {
|
if let Some(mgmt) = mgmt_port {
|
||||||
props.insert("mgmt".into(), mgmt.to_string());
|
props.insert("mgmt".into(), mgmt.to_string());
|
||||||
}
|
}
|
||||||
|
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
|
||||||
|
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
|
||||||
|
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
|
||||||
|
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
|
||||||
|
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
|
||||||
|
let macs = crate::wol::wake_macs(ip);
|
||||||
|
if !macs.is_empty() {
|
||||||
|
props.insert("mac".into(), macs.join(","));
|
||||||
|
}
|
||||||
|
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
|
||||||
|
// silently fails.
|
||||||
|
crate::wol::warn_if_not_armed(ip);
|
||||||
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||||
.context("build native mDNS ServiceInfo")?;
|
.context("build native mDNS ServiceInfo")?;
|
||||||
daemon
|
daemon
|
||||||
|
|||||||
@@ -276,12 +276,43 @@ impl DeckTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One-shot diagnostic: InputPlumber (shipped and enabled by default on Bazzite) hidraw-grabs
|
||||||
|
/// controllers it decides to manage and re-emits them under a different identity — historically
|
||||||
|
/// the Deck config re-emitted an Xbox Elite pad with the trackpads routed to a mouse target. If
|
||||||
|
/// it grabs our virtual Deck, everything downstream of hid-steam looks wrong (trackpads surface
|
||||||
|
/// as a stick/mouse, gyro vanishes) while punktfunk's own logs stay clean — so name the suspect
|
||||||
|
/// up front. Best-effort process-name scan; no dependency on its D-Bus API.
|
||||||
|
fn warn_if_inputplumber() {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||||
|
if !ONCE.swap(false, Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let running = std::fs::read_dir("/proc")
|
||||||
|
.ok()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.any(|e| {
|
||||||
|
std::fs::read_to_string(e.path().join("comm")).is_ok_and(|c| c.trim() == "inputplumber")
|
||||||
|
});
|
||||||
|
if running {
|
||||||
|
tracing::warn!(
|
||||||
|
"InputPlumber is running on this host — if it manages the virtual Steam Deck pad, \
|
||||||
|
games see InputPlumber's re-emitted device instead (trackpads may arrive as a \
|
||||||
|
stick/mouse, gyro may vanish). Check `inputplumber devices` and exclude the \
|
||||||
|
virtual pad from management if inputs look remapped."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the best Steam-Input-promotable Deck transport available, in preference order:
|
/// Open the best Steam-Input-promotable Deck transport available, in preference order:
|
||||||
/// **`raw_gadget` (SteamOS validated fast-path) → `usbip`/`vhci_hcd` (universal, Secure-Boot-clean)
|
/// **`raw_gadget` (SteamOS validated fast-path) → `usbip`/`vhci_hcd` (universal, Secure-Boot-clean)
|
||||||
/// → UHID (universal, but `Interface: -1` so Steam Input won't promote it).** Each rung degrades to
|
/// → UHID (universal, but `Interface: -1` so Steam Input won't promote it).** Each rung degrades to
|
||||||
/// the next on failure, so a host lacking the gadget modules still gets a *promotable* Deck via
|
/// the next on failure, so a host lacking the gadget modules still gets a *promotable* Deck via
|
||||||
/// usbip, and one lacking both still gets a working (if non-promoted) UHID pad.
|
/// usbip, and one lacking both still gets a working (if non-promoted) UHID pad.
|
||||||
fn open_transport(idx: u8) -> Result<DeckTransport> {
|
fn open_transport(idx: u8) -> Result<DeckTransport> {
|
||||||
|
warn_if_inputplumber();
|
||||||
use crate::inject::{steam_gadget, steam_usbip};
|
use crate::inject::{steam_gadget, steam_usbip};
|
||||||
// 1. raw_gadget — the validated SteamOS fast-path (default on there).
|
// 1. raw_gadget — the validated SteamOS fast-path (default on there).
|
||||||
if steam_gadget::gadget_preferred() {
|
if steam_gadget::gadget_preferred() {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ mod audio;
|
|||||||
mod capture;
|
mod capture;
|
||||||
mod config;
|
mod config;
|
||||||
mod discovery;
|
mod discovery;
|
||||||
|
mod wol;
|
||||||
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
||||||
// keeps the `crate::*` module names flat (every existing path is unchanged).
|
// keeps the `crate::*` module names flat (every existing path is unchanged).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -1911,6 +1911,13 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
|||||||
/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's
|
/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's
|
||||||
/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST`
|
/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST`
|
||||||
/// (uppercase); a UHID virtual device resolves through `/devices/virtual/…`, a real one does not.
|
/// (uppercase); a UHID virtual device resolves through `/devices/virtual/…`, a real one does not.
|
||||||
|
///
|
||||||
|
/// Punktfunk's OWN virtual Decks must never count: the usbip/gadget transports present a real USB
|
||||||
|
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
|
||||||
|
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
|
||||||
|
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
|
||||||
|
/// recognizable by the `PFDK…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
||||||
|
/// vhci path as belt and braces.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn physical_steam_controller_present() -> bool {
|
fn physical_steam_controller_present() -> bool {
|
||||||
let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else {
|
let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else {
|
||||||
@@ -1920,8 +1927,16 @@ fn physical_steam_controller_present() -> bool {
|
|||||||
if !e.file_name().to_string_lossy().contains(":28DE:") {
|
if !e.file_name().to_string_lossy().contains(":28DE:") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if std::fs::read_to_string(e.path().join("uevent"))
|
||||||
|
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=PFDK")))
|
||||||
|
{
|
||||||
|
return false; // one of our own virtual Decks
|
||||||
|
}
|
||||||
match std::fs::read_link(e.path()) {
|
match std::fs::read_link(e.path()) {
|
||||||
Ok(target) => !target.to_string_lossy().contains("/virtual/"),
|
Ok(target) => {
|
||||||
|
let t = target.to_string_lossy();
|
||||||
|
!t.contains("/virtual/") && !t.contains("vhci_hcd")
|
||||||
|
}
|
||||||
Err(_) => true,
|
Err(_) => true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//! Host-side Wake-on-LAN support.
|
||||||
|
//!
|
||||||
|
//! Two jobs, both best-effort (a failure here never affects streaming):
|
||||||
|
//! 1. [`wake_macs`] — report the host's wake-capable NIC MAC(s) so a client can persist them
|
||||||
|
//! (from the mDNS `mac` TXT record, [`crate::discovery`]) and wake this host later, once it's
|
||||||
|
//! asleep and no longer advertising.
|
||||||
|
//! 2. [`warn_if_not_armed`] — *detect & warn only* whether the NIC is actually armed to wake on a
|
||||||
|
//! magic packet. We never change NIC settings (that's the user's call); we just surface the
|
||||||
|
//! single most common reason WoL silently fails.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
/// Upper bound on advertised MACs — keeps the mDNS TXT record small. A host has at most a couple
|
||||||
|
/// of wake-capable NICs; the routed one is always first.
|
||||||
|
const MAX_MACS: usize = 4;
|
||||||
|
|
||||||
|
/// MAC(s) of the host's wake-capable NIC(s), lowercase `aa:bb:cc:dd:ee:ff`, with the NIC that
|
||||||
|
/// bears `primary_ip` (the address clients reach us on) FIRST, then other non-loopback NICs as
|
||||||
|
/// fallbacks. Best-effort — an empty list just means clients can't auto-wake (they fall back to
|
||||||
|
/// manual MAC entry). Deduped; all-zero MACs skipped; capped at [`MAX_MACS`].
|
||||||
|
pub fn wake_macs(primary_ip: IpAddr) -> Vec<String> {
|
||||||
|
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
|
||||||
|
|
||||||
|
// Interface names in priority order: the one holding `primary_ip` first, then every other
|
||||||
|
// non-loopback interface that has an IP, de-duplicated by name (an iface has one MAC but may
|
||||||
|
// appear once per address).
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
if let Some(primary) = ifaces.iter().find(|i| i.ip() == primary_ip) {
|
||||||
|
names.push(primary.name.clone());
|
||||||
|
}
|
||||||
|
for i in &ifaces {
|
||||||
|
if i.is_loopback() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !names.contains(&i.name) {
|
||||||
|
names.push(i.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
let Ok(Some(mac)) = mac_address::mac_address_by_name(&name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let b = mac.bytes();
|
||||||
|
if b == [0u8; 6] {
|
||||||
|
continue; // unset / virtual
|
||||||
|
}
|
||||||
|
let s = format!(
|
||||||
|
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
|
||||||
|
b[0], b[1], b[2], b[3], b[4], b[5]
|
||||||
|
);
|
||||||
|
if !out.contains(&s) {
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
if out.len() >= MAX_MACS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log whether the host NIC bearing `primary_ip` is armed to wake on a magic packet. Detect &
|
||||||
|
/// warn only — never modifies settings. Linux-only (reads `ethtool <iface>`); a no-op elsewhere
|
||||||
|
/// and silent when it can't tell (no `ethtool`, insufficient privilege).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||||
|
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
|
||||||
|
let Some(iface) = ifaces
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.ip() == primary_ip)
|
||||||
|
.map(|i| i.name.clone())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match ethtool_wol_has_magic(&iface) {
|
||||||
|
Some(true) => {
|
||||||
|
tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC")
|
||||||
|
}
|
||||||
|
Some(false) => tracing::warn!(
|
||||||
|
iface = %iface,
|
||||||
|
"Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. \
|
||||||
|
Enable it with: sudo ethtool -s {iface} wol g (and turn on 'Wake on LAN'/'Wake on \
|
||||||
|
PCIe' in BIOS). Wired Ethernet is required; Wi-Fi wake is unreliable.",
|
||||||
|
),
|
||||||
|
None => {} // couldn't determine — stay quiet rather than cry wolf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn warn_if_not_armed(_primary_ip: IpAddr) {}
|
||||||
|
|
||||||
|
/// Parse `ethtool <iface>` for the *current* Wake-on setting and report whether it includes `g`
|
||||||
|
/// (wake on MagicPacket). Returns `None` if ethtool is missing/failed or the field is absent.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||||
|
let out = std::process::Command::new("ethtool")
|
||||||
|
.arg(iface)
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
for line in text.lines() {
|
||||||
|
let t = line.trim();
|
||||||
|
// The current setting is "Wake-on: <flags>"; skip the "Supports Wake-on: ..." capability
|
||||||
|
// line. `g` = MagicPacket, `d` = disabled.
|
||||||
|
if let Some(flags) = t.strip_prefix("Wake-on:") {
|
||||||
|
return Some(flags.trim().contains('g'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -136,8 +136,14 @@ reason "admin/SYSTEM = total" stays on the residual list below.
|
|||||||
boundary against admin. The host↔driver channel has no mutual authentication beyond the `GET_INFO`
|
boundary against admin. The host↔driver channel has no mutual authentication beyond the `GET_INFO`
|
||||||
version handshake + the `verify_is_wudfhost` image check.
|
version handshake + the `verify_is_wudfhost` image check.
|
||||||
* **`WDA_EXCLUDEFROMCAPTURE` windows are visible.** IDD-push taps the *present* side, not the
|
* **`WDA_EXCLUDEFROMCAPTURE` windows are visible.** IDD-push taps the *present* side, not the
|
||||||
*capture* side, so windows that exclude themselves from capture still appear in the stream — true
|
*capture* side, so windows that exclude themselves from capture still appear in the stream. This is
|
||||||
of every virtual-display streaming stack. Untested on our lab box; treat as expected behavior.
|
the same exposure a person looking at the physical screen has (the flag hides a window from capture
|
||||||
|
APIs, not from the display), so it fits inside the "a client sees what someone at the screen sees"
|
||||||
|
model rather than exceeding it; what it exceeds is an ordinary screen-*capture* tool (OBS/WGC/DDA),
|
||||||
|
which honors the flag. **Measured, not assumed (2026-07-04, .173):** a full-screen test window was
|
||||||
|
streamed through three 8 s phases — no flag / `WDA_EXCLUDEFROMCAPTURE` set (affinity readback `0x11`,
|
||||||
|
confirmed active) / flag cleared — and the window was pixel-identically visible in the decoded
|
||||||
|
punktfunk/1 stream in all three. The flag made no difference to the stream.
|
||||||
* **DRM/HDCP:** protected content is blanked by DWM at composition, and HDCP is a monitor↔GPU
|
* **DRM/HDCP:** protected content is blanked by DWM at composition, and HDCP is a monitor↔GPU
|
||||||
handshake an indirect display cannot satisfy — neither is bypassed by this path.
|
handshake an indirect display cannot satisfy — neither is bypassed by this path.
|
||||||
* IDD-push is currently the **sole Windows capture path** (DDA and the WGC relay were removed). An
|
* IDD-push is currently the **sole Windows capture path** (DDA and the WGC relay were removed). An
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ virtual display is a real monitor: any process already running in your desktop s
|
|||||||
through the ordinary OS screen-capture APIs, exactly as it could capture a physical monitor. That floor
|
through the ordinary OS screen-capture APIs, exactly as it could capture a physical monitor. That floor
|
||||||
is the same for every virtual-display streaming stack.
|
is the same for every virtual-display streaming stack.
|
||||||
|
|
||||||
|
One nuance specific to how the Windows host captures: because it reads the composed desktop image (what
|
||||||
|
the monitor shows) rather than going through Windows' screen-capture APIs, a window that hides itself
|
||||||
|
from *recording* tools with `WDA_EXCLUDEFROMCAPTURE` still appears in the stream — just as it appears to
|
||||||
|
anyone looking at the physical screen. Conversely, DRM-protected video (Netflix and the like) is blanked
|
||||||
|
by Windows for any capture path, so it shows as black rather than the protected frames. Neither weakens
|
||||||
|
Windows' protections: the first is exactly what a person at the screen already sees, and the second is
|
||||||
|
Windows enforcing its own rule. The consistent way to think about it is the one from the top of this
|
||||||
|
page — **a connected client sees and does what a person sitting at that machine could**, no more (and,
|
||||||
|
for DRM content, slightly less).
|
||||||
|
|
||||||
**Recommendation:** run the Windows host on a **dedicated or gaming PC**, not on a machine that also
|
**Recommendation:** run the Windows host on a **dedicated or gaming PC**, not on a machine that also
|
||||||
holds your most sensitive material (work laptop, financial records, the box with your password vault).
|
holds your most sensitive material (work laptop, financial records, the box with your password vault).
|
||||||
A gaming rig you stream from is a great fit; your primary secrets machine is not.
|
A gaming rig you stream from is a great fit; your primary secrets machine is not.
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
//
|
//
|
||||||
// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
||||||
// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
||||||
#define ABI_VERSION 2
|
// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
|
||||||
|
// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
|
||||||
|
#define ABI_VERSION 3
|
||||||
|
|
||||||
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||||
#define PUNKTFUNK_HIDOUT_LED 1
|
#define PUNKTFUNK_HIDOUT_LED 1
|
||||||
@@ -804,6 +806,23 @@ extern "C" {
|
|||||||
// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
|
// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
|
||||||
uint32_t punktfunk_abi_version(void);
|
uint32_t punktfunk_abi_version(void);
|
||||||
|
|
||||||
|
// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s).
|
||||||
|
//
|
||||||
|
// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) —
|
||||||
|
// a host may report several NICs; all are woken. `last_known_ip`, if non-NULL, is an IPv4
|
||||||
|
// dotted-quad string additionally targeted by unicast (pass NULL to skip). The packet is
|
||||||
|
// broadcast to every local interface's subnet-directed broadcast and to `255.255.255.255` on
|
||||||
|
// ports 9 and 7. This does NOT require an open connection and is not part of the QUIC surface.
|
||||||
|
//
|
||||||
|
// Returns `Ok` if at least one datagram was sent. Call off the UI thread.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL,
|
||||||
|
// must be a NUL-terminated string.
|
||||||
|
PunktfunkStatus punktfunk_wake_on_lan(const uint8_t *macs,
|
||||||
|
uintptr_t mac_count,
|
||||||
|
const char *last_known_ip);
|
||||||
|
|
||||||
// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
|
// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
|
||||||
// Returns NULL on error.
|
// Returns NULL on error.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -62,6 +62,38 @@ systemctl reboot
|
|||||||
> The **reboot is mandatory** — `rpm-ostree install` stages a new deployment that only takes
|
> The **reboot is mandatory** — `rpm-ostree install` stages a new deployment that only takes
|
||||||
> effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk.
|
> effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk.
|
||||||
|
|
||||||
|
#### Updating a Path-A host — `rpm-ostree upgrade` is NOT enough
|
||||||
|
|
||||||
|
> ⚠️ **`rpm-ostree upgrade` will not update punktfunk on its own.** `upgrade` bumps the **base
|
||||||
|
> image** and only re-resolves *layered* packages **when the base changes**. A Bazzite base can
|
||||||
|
> sit frozen for months (a pinned `:stable` tag, a paused rebase), so `rpm-ostree upgrade` keeps
|
||||||
|
> reporting *"No updates available"* and your layered `punktfunk` stays put even after new RPMs
|
||||||
|
> land in the repo. (Diagnose: `rpm-ostree status` shows the base `Version:` unchanged, while
|
||||||
|
> `dnf -q repoquery --upgrades punktfunk` lists newer builds.)
|
||||||
|
|
||||||
|
To actually pull a newer host on a static base, force rpm-ostree to re-resolve just the punktfunk
|
||||||
|
layer — remove + re-add the same names in one transaction:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo rpm-ostree refresh-md --force
|
||||||
|
sudo rpm-ostree update \
|
||||||
|
--uninstall punktfunk --uninstall punktfunk-web \
|
||||||
|
--install punktfunk --install punktfunk-web
|
||||||
|
systemctl reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
Or just run the helper, which detects what's layered and does the above:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo bash packaging/bazzite/update-punktfunk.sh # stage; reboot when ready
|
||||||
|
sudo bash packaging/bazzite/update-punktfunk.sh --reboot # stage + reboot now
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Channel gotcha:** the re-resolve picks the highest version across **every enabled**
|
||||||
|
> `/etc/yum.repos.d/punktfunk*.repo`. If `punktfunk-canary.repo` is enabled alongside the stable
|
||||||
|
> `punktfunk.repo`, canary's `<next-minor>.0-0.ciN` **outranks** the stable `X.Y.Z-1` and the box
|
||||||
|
> silently tracks canary. Enable exactly one channel — set `enabled=0` in the other repo file.
|
||||||
|
|
||||||
### Path B — bootc image (`FROM bazzite-nvidia`)
|
### Path B — bootc image (`FROM bazzite-nvidia`)
|
||||||
|
|
||||||
The image is built **off-host** (on any machine with `podman`) from
|
The image is built **off-host** (on any machine with `podman`) from
|
||||||
|
|||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Update the layered punktfunk packages on a Bazzite / Fedora-Atomic host.
|
||||||
|
#
|
||||||
|
# Why this exists: `rpm-ostree upgrade` upgrades the *base image* and only re-resolves
|
||||||
|
# layered packages WHEN THE BASE CHANGES. Bazzite bases can sit frozen for months (a pinned
|
||||||
|
# `:stable` tag, a paused rebase), so `rpm-ostree upgrade` keeps reporting "No updates
|
||||||
|
# available" and your layered punktfunk never moves even though newer RPMs are in the repo.
|
||||||
|
# The fix is to force rpm-ostree to re-resolve just the punktfunk layer against the latest
|
||||||
|
# repo metadata — an `--uninstall … --install …` of the same package names in one
|
||||||
|
# transaction. This script does that for whichever of punktfunk / punktfunk-web are layered.
|
||||||
|
#
|
||||||
|
# Usage: sudo bash update-punktfunk.sh # stage the newest; you reboot when ready
|
||||||
|
# sudo bash update-punktfunk.sh --reboot # stage, then reboot immediately
|
||||||
|
#
|
||||||
|
# Channel note: it re-resolves against every ENABLED punktfunk repo. If both
|
||||||
|
# `punktfunk.repo` (stable) and `punktfunk-canary.repo` are enabled, canary's version sorts
|
||||||
|
# higher and WINS — the box silently tracks canary. Enable exactly the channel you want
|
||||||
|
# (set `enabled=0` in the other `/etc/yum.repos.d/punktfunk*.repo`).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "run as root: sudo bash $0 ${*:-}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Which punktfunk packages are actually layered right now (host, web, or both).
|
||||||
|
mapfile -t layered < <(rpm-ostree status --json 2>/dev/null \
|
||||||
|
| grep -oE '"punktfunk(-web)?"' | tr -d '"' | sort -u)
|
||||||
|
if [[ ${#layered[@]} -eq 0 ]]; then
|
||||||
|
# Fall back to the rpm db if the JSON shape ever changes.
|
||||||
|
mapfile -t layered < <(rpm -qa --qf '%{NAME}\n' 'punktfunk' 'punktfunk-web' 2>/dev/null | sort -u)
|
||||||
|
fi
|
||||||
|
if [[ ${#layered[@]} -eq 0 ]]; then
|
||||||
|
echo "no punktfunk packages are layered — install first (see packaging/bazzite/README.md)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "layered punktfunk packages: ${layered[*]}"
|
||||||
|
|
||||||
|
# Fresh repo metadata, else the re-resolve can pick a stale 'newest'.
|
||||||
|
rpm-ostree refresh-md --force >/dev/null
|
||||||
|
|
||||||
|
# Force the re-resolve: remove + re-add the same names in ONE transaction so the box is never
|
||||||
|
# left without the host, and rpm-ostree picks the newest available version.
|
||||||
|
args=()
|
||||||
|
for p in "${layered[@]}"; do args+=(--uninstall "$p"); done
|
||||||
|
for p in "${layered[@]}"; do args+=(--install "$p"); done
|
||||||
|
echo "+ rpm-ostree update ${args[*]}"
|
||||||
|
rpm-ostree update "${args[@]}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Staged. The new version activates on the next boot."
|
||||||
|
if [[ "${1:-}" == "--reboot" ]]; then
|
||||||
|
echo "rebooting now…"
|
||||||
|
systemctl reboot
|
||||||
|
else
|
||||||
|
echo "Reboot when ready: systemctl reboot"
|
||||||
|
fi
|
||||||
@@ -64,10 +64,16 @@ finish-args:
|
|||||||
# does not apply.
|
# does not apply.
|
||||||
- --device=all
|
- --device=all
|
||||||
- --filesystem=/run/udev:ro # SDL/HIDAPI enumerates devices via udev
|
- --filesystem=/run/udev:ro # SDL/HIDAPI enumerates devices via udev
|
||||||
# --- audio: PipeWire via its PulseAudio shim — covers playback AND mic uplink. SteamOS
|
# --- audio: the client speaks the NATIVE PipeWire protocol (audio.rs `pw connect`), NOT the
|
||||||
# exposes PipeWire-pulse here; --socket=pulseaudio is the portable arg Moonlight/chiaki
|
# PulseAudio shim — so it needs the real `pipewire-0` socket in the sandbox. With only
|
||||||
# also use on the Deck (a bare --socket=pipewire would also need the camera/portal dance
|
# --socket=pulseaudio the sandbox has just `pulse/native`, no `pipewire-0`, and playback +
|
||||||
# for capture; the pulse shim gives mic + speaker in one grant). ---
|
# mic both die with "pw connect (is PipeWire running in this session?)" (observed live on the
|
||||||
|
# Deck in Gaming Mode). We bind the native socket via --filesystem=xdg-run/pipewire-0 (NOT
|
||||||
|
# --socket=pipewire: this flatpak-builder toolchain rejects it as an "Unknown socket type",
|
||||||
|
# and the Deck's flatpak 1.16 override CLI does too — the filesystem bind is the portable
|
||||||
|
# form, validated on-Deck to make pipewire-0 appear + the client register its audio node).
|
||||||
|
# --socket=pulseaudio stays as a fallback for any pulse-only path. ---
|
||||||
|
- --filesystem=xdg-run/pipewire-0
|
||||||
- --socket=pulseaudio
|
- --socket=pulseaudio
|
||||||
# --- network: QUIC control + UDP data plane + mDNS discovery (_punktfunk._udp) ---
|
# --- network: QUIC control + UDP data plane + mDNS discovery (_punktfunk._udp) ---
|
||||||
- --share=network
|
- --share=network
|
||||||
|
|||||||
Reference in New Issue
Block a user