14 Commits

Author SHA1 Message Date
enricobuehler 160b67d043 fix(apple/release): embed Developer ID provisioning profile in the DMG
decky / build-publish (push) Successful in 20s
apple / swift (push) Successful in 1m8s
windows-host / package (push) Successful in 7m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m12s
ci / web (push) Successful in 47s
ci / rust (push) Successful in 12m28s
ci / docs-site (push) Successful in 58s
ci / bench (push) Successful in 5m4s
release / apple (push) Successful in 9m21s
apple / screenshots (push) Successful in 5m42s
android-screenshots / screenshots (push) Successful in 2m25s
android / android (push) Successful in 3m34s
deb / build-publish (push) Successful in 4m49s
flatpak / build-publish (push) Successful in 4m21s
linux-client-screenshots / screenshots (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m56s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m51s
docker / deploy-docs (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
web-screenshots / screenshots (push) Successful in 2m33s
The notarized Developer ID .dmg was SIGKILLed at launch ("Launchd job spawn
failed", POSIX errno 163) before main() ran: the sandboxed macOS app declares
the MANAGED keychain-access-groups entitlement, which AMFI only honors when an
embedded provisioning profile authorizes it. The DMG embedded none — App Sandbox
and the network/device keys are self-asserted for Developer ID, but a keychain
access group is not — so every launch was killed at spawn. Validly signed and
notarized (Gatekeeper accepted it), which is why this looked like a mystery. ⌘R
and the App Store build hid it: Xcode embeds a development / App Store profile;
the raw-codesign DMG path did not, so "⌘R == DMG" never held for this entitlement.

Embed a "Punktfunk macOS Developer ID" profile (Keychain Sharing) into
Contents/embedded.provisionprofile before codesign so its entitlements authorize
the access group, exactly like the App Store build's profile does. If the profile
isn't installed on the runner, warn and strip keychain-access-groups instead so
the app still launches via ClientIdentityStore's legacy file-keychain fallback —
a missing/expired profile can never reship the errno-163 brick again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:00:56 +02:00
enricobuehler 6c4ba77606 fix(wol): clippy + cfg-gate the Windows client module — main compiles again
windows-host / package (push) Successful in 7m18s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 56s
apple / swift (push) Successful in 1m16s
android / android (push) Successful in 3m40s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m16s
ci / bench (push) Successful in 4m42s
release / apple (push) Successful in 8m37s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
deb / build-publish (push) Successful in 3m45s
apple / screenshots (push) Successful in 5m29s
flatpak / build-publish (push) Successful in 4m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m51s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m26s
The Wake-on-LAN batch landed with lints that fail `clippy -D warnings`
(doc continuation, char-array split, io::Error::other, redundant closure)
and an ungated `mod wol;` in the Windows client, which pulls windows-only
crates into the non-Windows stub build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:02:45 +00:00
enricobuehler eeee2782f5 Merge remote-tracking branch 'origin/main'
apple / screenshots (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
windows-host / package (push) Failing after 2m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 41s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 42s
apple / swift (push) Successful in 1m16s
android / android (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
2026-07-04 12:00:18 +00:00
enricobuehler b488bd1d99 feat(client-linux): in-process GL presenter — hardware decode ships on the Steam Deck
VAAPI decode stays; what changes is who touches the YUV. The direct path hands
the NV12 dmabuf (tiled AMD modifier since Mesa 25.1) to GdkDmabufTexture, and
GTK's tiled-NV12 import renders corrupt/gray/washed-out on the Deck. Moonlight
and mpv are clean on the same box because they import the dmabuf into their own
EGL context and convert with their own shader — video_gl.rs is that
architecture for the GTK client: per-plane EGLImages (R8 + GR88, modifier
passed through) → our YUV→RGB shader (matrix/range from the stream's CICP
signaling, unit-tested) → RGBA texture in a GdkGLContext-shared context →
fence-synced GdkGLTexture. GTK composites plain RGBA; no YUV negotiation, no
compositor CSC.

The Deck's decoder default flips back to hardware (the software stopgap is
gone); desktops keep the direct dmabuf path (offload/scan-out eligible).
PUNKTFUNK_PRESENT=direct|gl overrides either way. New failure ladder: GL
converter init failure or a convert-error streak raises a shared flag and the
session pump demotes the decoder to software with a keyframe re-request — the
same mechanism also closes the old silent-black-screen gap where a rejected
dmabuf import had no recovery at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:00:18 +00:00
enricobuehler 7e6561aaa2 style: rustfmt the Wake-on-LAN modules
ci / rust (push) Failing after 51s
ci / web (push) Successful in 53s
windows-host / package (push) Failing after 2m54s
apple / swift (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m10s
android / android (push) Successful in 3m38s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m21s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 39s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 41s
decky / build-publish (push) Successful in 13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
ci / bench (push) Successful in 4m48s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
release / apple (push) Successful in 8m47s
deb / build-publish (push) Successful in 9m26s
flatpak / build-publish (push) Successful in 4m44s
apple / screenshots (push) Successful in 5m56s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
docker / deploy-docs (push) Successful in 17s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:52:17 +02:00
enricobuehler e9c5030190 feat(clients): Wake-on-LAN in apple/linux/windows/android/decky
apple / swift (push) Successful in 1m7s
ci / rust (push) Failing after 49s
ci / web (push) Successful in 52s
audit / cargo-audit (push) Successful in 1m14s
windows-host / package (push) Failing after 2m58s
ci / docs-site (push) Successful in 1m5s
android / android (push) Successful in 4m7s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 48s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 49s
ci / bench (push) Successful in 5m5s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
apple / screenshots (push) Has been cancelled
docker / deploy-docs (push) Successful in 19s
Each client learns a host's MAC from the mDNS `mac` TXT while it's awake, persists it on the saved-host record, and — when reconnecting to an offline host — sends a magic packet before connecting, plus an explicit "Wake host" action. Apple wraps the C-ABI; linux/windows call the core fn directly (linux also gains a --wake CLI mode); android via a new nativeWakeOnLan JNI export (the mDNS browse record gains a 7th mac field); decky shells out to the linux client's --wake before launching the stream.

iOS/tvOS need the managed com.apple.developer.networking.multicast entitlement (pending Apple approval), so the wake path + UI are gated off via PunktfunkConnection.wakeOnLANAvailable and the entitlement is commented out — keeping iOS/tvOS releasable. MAC-learning stays active on every platform so it lights up the moment it's ungated. macOS works today.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:39:44 +02:00
enricobuehler 22c0d92f2e feat(core,host): Wake-on-LAN sender + host MAC advertisement
Add a runtime-free Wake-on-LAN sender in punktfunk-core (per-interface subnet-directed broadcast + 255.255.255.255 on ports 9/7, repeated, optional last-known-IP unicast) exposed both as a Rust fn and a punktfunk_wake_on_lan C-ABI (ABI v3), plus a parse_mac helper. The host enumerates its wake-capable NIC MAC(s) and advertises them in a new mDNS `mac` TXT record (routed NIC first), and best-effort detects & warns (never modifies) when the NIC isn't armed for WoL.

MAC delivery is via the unauthenticated mDNS TXT rather than the connection handshake by design: a spoofed MAC only makes a wake fail (the packet is inert; the cert fingerprint still gates the connection), and it avoids threading through the hot connect path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:39:44 +02:00
enricobuehler 097cc6faf4 fix(apple/gamepad): deliver PS/Home + Share buttons on macOS
macOS reserves the controller Home/PS and Share/Create buttons for its own system gestures and never delivers them to the app unless it declares the Game Controllers capability. Add GCSupportsControllerUserInteraction=YES to the macOS target only (iOS/tvOS rely on the focus engine, so it must not be in the shared plist), alongside the existing preferredSystemGestureState=.disabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:39:44 +02:00
enricobuehler 8b37badae4 docs(security): record measured WDA_EXCLUDEFROMCAPTURE behavior + capture-vs-viewer framing
Tested on .173: a WDA_EXCLUDEFROMCAPTURE window (affinity readback 0x11,
confirmed active) is pixel-identically visible in the punktfunk/1 stream
across no-flag / flag-set / flag-cleared phases — the flag makes no
difference to a present-tap capture. Replace the "untested, treat as
expected" note in the IDD-push residual list with the measured result,
and correct the framing: WDA visibility matches what a person at the
screen sees (it exceeds an ordinary capture tool, not the physical
viewer).

Add the matching public-facing paragraph to the security page covering
both asymmetries — WDA windows appear (same as a physical viewer), DRM
video is blanked (less than a physical viewer) — tied back to the page's
"a client sees what someone at the machine sees" model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:16:18 +00:00
enricobuehler 90c2d8b3a0 fix(host): don't count punktfunk's own virtual Deck as a physical Steam controller
apple / swift (push) Successful in 1m7s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
The Steam-conflict gate scanned /sys/bus/hid/devices for non-virtual 28DE
devices, but the usbip/gadget virtual Decks 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
Steam controller attached" and degraded every back-to-back Deck session to
DualSense (observed live on Bazzite). Exclude our pads by their PFDK… serial
(HID_UNIQ), with the vhci_hcd path as belt and braces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:14:24 +00:00
enricobuehler 853e7fe92f fix(client-linux): Deck trackpad clicks — bind to the correct pad, stop riding the button plane
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 1m27s
ci / web (push) Successful in 50s
android / android (push) Successful in 3m19s
ci / docs-site (push) Successful in 1m6s
apple / screenshots (push) Successful in 5m29s
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
SDL's Steam Deck mapping delivers the pad clicks as gamepad BUTTONS with no
surface identity: the generic `touchpad` button is the LEFT pad's click and
`misc2` the RIGHT's (SDL_gamepad_db.h `touchpad:b17,misc2:b16`). The client
forwarded `touchpad` as wire BTN_TOUCHPAD — which the host maps to the RIGHT
pad click (DualSense convention) — and dropped `misc2` entirely: a left-pad
click registered on the right pad, a right-pad click nowhere, and the
mis-routed state could stick.

Clicks from a multi-touchpad pad now ride the rich plane as TouchpadEx with
their surface, reusing the surface's live contact point (click buttons carry
no position). forward_touch carries the held click through motion frames so a
touch update can't clear a click mid-press, and the flush lifts held clicks on
detach/pad-switch. A DualSense's single touchpad button stays on the button
plane unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:08:44 +00:00
enricobuehler df496776b0 fix(client-linux): Deck raw-pad capture — clear Steam's SDL device filter, honest degradation warning
apple / swift (push) Successful in 1m14s
apple / screenshots (push) Successful in 5m41s
android / android (push) Successful in 3m46s
ci / web (push) Successful in 49s
ci / rust (push) Successful in 1m23s
ci / docs-site (push) Successful in 58s
ci / bench (push) Successful in 4m52s
deb / build-publish (push) Successful in 4m34s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Successful in 4m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m58s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m8s
The Deck's built-in controller can never leave Steam Input ("Steam Controller"
is always-required in the shortcut's matrix; Disable Steam Input only affects
other controller brands), so the raw 28DE:1205 device is the only path to the
trackpads/paddles/gyro. Steam hides it from SDL by launching shortcuts with
SDL_GAMECONTROLLER_IGNORE_DEVICES naming every physical pad it virtualized —
clear it (and _EXCEPT) at startup while single-threaded, logging what Steam set
as field evidence. The post-attach warning now states the real condition (raw
pad never enumerated; sticks + buttons still work) instead of advising a
Steam Input toggle that doesn't exist for the built-in controller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:06:48 +00:00
enricobuehler 5310176ab5 fix(client-linux,host): Deck video defaults to software decode + input-interception diagnostics
apple / swift (push) Successful in 1m8s
apple / screenshots (push) Successful in 5m38s
windows-host / package (push) Successful in 7m12s
android / android (push) Successful in 3m36s
ci / rust (push) Successful in 1m31s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 57s
ci / bench (push) Successful in 4m56s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
deb / build-publish (push) Successful in 4m38s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
flatpak / build-publish (push) Successful in 4m55s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m57s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m21s
docker / deploy-docs (push) Successful in 17s
Video (Deck): the VAAPI zero-copy path renders corrupt/gray/washed-out on the
Deck — root-caused to Mesa >= 25.1 exporting radeonsi VCN decode surfaces TILED
(the Flatpak runtime's Mesa 26 drives both the decoder and GTK's GL, and GTK's
tiled-NV12 dmabuf import mishandles it; desktop Tier-1 validations ran distro
Mesa with linear export). `auto` now resolves to software on a Deck (clean,
correct-colour, easily handles 1280x800 HEVC); PUNKTFUNK_DECODER=vaapi still
forces the hw path, with the descriptor modifier dump + GSK_RENDERER as the
bisect levers. Also reserve extra_hw_frames=4 on the VAAPI decoder: the
presenter pins mapped surfaces past receive_frame, and the fixed pool recycling
a surface the renderer still samples is intermittent block corruption anywhere.

Input (Deck): with Steam Input ON for Punktfunk, SDL sees only Steam's virtual
X360 pad — the right trackpad arrives as a plain right stick and the left
trackpad/paddles/gyro not at all, silently. The client now checks once the
post-attach enumeration settles and raises a toast + warn naming the fix
(disable Steam Input for the shortcut). The host logs a one-shot warning when
InputPlumber is running (Bazzite default) since it can grab the virtual Deck
pad and re-emit it under a different identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:56:06 +00:00
enricobuehler 76ff616dcf fix(flatpak): drop --socket=pipewire (unknown to the builder) — keep the xdg-run bind
apple / swift (push) Successful in 1m13s
apple / screenshots (push) Successful in 5m46s
android / android (push) Successful in 11m8s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 57s
ci / rust (push) Successful in 12m53s
ci / bench (push) Successful in 4m44s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 4m34s
flatpak / build-publish (push) Successful in 4m5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m54s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m45s
docker / deploy-docs (push) Successful in 18s
The v0.7.2 flatpak build failed: `error: Unknown socket type pipewire` — this
flatpak-builder toolchain (and the Deck's flatpak 1.16 override CLI) don't
accept --socket=pipewire. --filesystem=xdg-run/pipewire-0 binds the same native
socket and is the portable form already validated on-Deck (pipewire-0 appears
in the sandbox, client audio node registers, no pw-connect error). Keep only
that + --socket=pulseaudio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:20:52 +00:00
57 changed files with 2102 additions and 53 deletions
+46 -5
View File
@@ -14,8 +14,12 @@
# 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
# 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
# locally equals what App Store users get.
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
# 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
# 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
# .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
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
@@ -156,9 +169,8 @@ jobs:
run: |
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
# provisioning-profile gate; codesign just needs the (now valid) identity + the
# team-prefixed entitlements, no profile (App Sandbox + the network/device
# capabilities are self-asserted for Developer ID — no profile entry needed).
# provisioning-profile gate at archive time; we re-assert that authorization below by
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
# Bundle is a single static binary.
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk \
@@ -173,6 +185,35 @@ jobs:
RESOLVED="$RUNNER_TEMP/macos.entitlements"
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
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 \
--entitlements "$RESOLVED" \
--sign "Developer ID Application" "$APP"
Generated
+62 -3
View File
@@ -1952,6 +1952,16 @@ dependencies = [
"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]]
name = "if-addrs"
version = "0.15.0"
@@ -2195,7 +2205,7 @@ dependencies = [
"cookie-factory",
"libc",
"libspa-sys",
"nix",
"nix 0.30.1",
"nom 8.0.0",
"system-deps",
]
@@ -2262,6 +2272,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "matchers"
version = "0.2.0"
@@ -2285,7 +2305,7 @@ checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e"
dependencies = [
"fastrand",
"flume",
"if-addrs",
"if-addrs 0.15.0",
"log",
"mio",
"socket-pktinfo",
@@ -2383,6 +2403,19 @@ dependencies = [
"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]]
name = "nix"
version = "0.30.1"
@@ -2742,7 +2775,7 @@ dependencies = [
"libc",
"libspa",
"libspa-sys",
"nix",
"nix 0.30.1",
"once_cell",
"pipewire-sys",
"thiserror 2.0.18",
@@ -2895,6 +2928,7 @@ dependencies = [
"async-channel",
"ffmpeg-next",
"gtk4",
"khronos-egl",
"libadwaita",
"mdns-sd",
"opus",
@@ -2942,6 +2976,7 @@ dependencies = [
"criterion",
"fec-rs",
"hmac",
"if-addrs 0.13.4",
"libc",
"opus",
"proptest",
@@ -2982,10 +3017,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
"if-addrs 0.13.4",
"khronos-egl",
"libc",
"libloading",
"log",
"mac_address",
"mdns-sd",
"nvidia-video-codec-sdk",
"openh264",
@@ -4765,6 +4802,22 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "winapi-util"
version = "0.1.11"
@@ -4774,6 +4827,12 @@ dependencies = [
"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]]
name = "windows"
version = "0.62.2"
@@ -124,6 +124,25 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val identityStore = remember { IdentityStore(context) }
val knownHostStore = remember { KnownHostStore(context) }
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
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
@@ -176,6 +195,14 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
connecting = true
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
scope.launch {
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
@@ -359,6 +386,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
savedHosts = knownHostStore.all()
},
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,
onForget: (() -> Unit)?,
onRename: (() -> Unit)? = null,
onWake: (() -> Unit)? = null,
) {
// 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.
@@ -107,7 +108,7 @@ fun HostCard(
StatusPill(status)
}
if (onForget != null || onRename != null) {
if (onForget != null || onRename != null || onWake != null) {
var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) {
@@ -119,6 +120,15 @@ fun HostCard(
)
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
if (onWake != null) {
DropdownMenuItem(
text = { Text("Wake host") },
onClick = {
menu = false
onWake()
},
)
}
if (onRename != null) {
DropdownMenuItem(
text = { Text("Rename") },
@@ -86,7 +86,7 @@ object NativeBridge {
/**
* 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.
*/
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`. */
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
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
@@ -17,15 +17,17 @@ data class DiscoveredHost(
val port: Int,
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
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). */
private const val FIELD_SEP = '\u001F'
/**
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
* already applied the protocol gate and address selection, so this is just field marshaling.
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac`), or
* null if it's malformed. `mac` (7th field) is optional — an older host omits it. Pure —
* unit-tested without Android (see ParseRecordTest). The native side already applied the protocol
* gate and address selection, so this is just field marshaling.
*/
fun parseHostRecord(record: String): DiscoveredHost? {
val f = record.split(FIELD_SEP)
@@ -40,6 +42,8 @@ fun parseHostRecord(record: String): DiscoveredHost? {
port = port,
fingerprint = f[4].ifBlank { null },
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 fpHex: String,
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("fp", host.fpHex.lowercase())
.put("paired", host.paired)
.put("mac", host.mac.joinToString(","))
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). */
fun remove(address: String, port: Int) {
prefs.edit().remove(key(address, port)).apply()
@@ -68,6 +86,7 @@ class KnownHostStore(context: Context) {
name = j.getString("name"),
fpHex = j.getString("fp"),
paired = j.optBoolean("paired", false),
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
)
}.getOrNull()
}
+17 -6
View File
@@ -31,7 +31,7 @@ const PROTO: &str = "punktfunk/1";
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
const FIELD_SEP: char = '\u{1f}';
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
/// 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
/// every field so no value can break it.
#[derive(Clone, PartialEq)]
@@ -42,6 +42,8 @@ struct Host {
port: u16,
fp: 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 {
@@ -54,13 +56,14 @@ impl Host {
s.replace(['\n', '\r', FIELD_SEP], "")
}
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.name),
clean(&self.addr),
self.port,
clean(&self.fp),
clean(&self.pair),
clean(&self.mac),
)
}
}
@@ -182,6 +185,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
port: info.get_port(),
fp: val("fp"),
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,
/// 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).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
@@ -263,16 +267,18 @@ mod tests {
port: 9777,
fp: "ab".repeat(32),
pair: "required".into(),
mac: "aa:bb:cc:dd:ee:ff".into(),
};
let encoded = h.encode();
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[1], "home-worker-2");
assert_eq!(fields[2], "192.168.1.70");
assert_eq!(fields[3], "9777");
assert_eq!(fields[4], "ab".repeat(32));
assert_eq!(fields[5], "required");
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
assert!(
!encoded.contains('\n'),
"a record must never contain the record separator"
@@ -282,7 +288,7 @@ mod tests {
#[test]
fn encode_strips_injected_separators_from_a_hostile_advert() {
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
// them so the snapshot stays exactly one record of exactly six fields.
// them so the snapshot stays exactly one record of exactly seven fields.
let h = Host {
key: "k\u{1f}injected".into(),
name: "evil\nhost\r".into(),
@@ -290,9 +296,14 @@ mod tests {
port: 9777,
fp: "ab\u{1f}cd".into(),
pair: "required\n".into(),
mac: "aa:bb\u{1f}cc".into(),
};
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'));
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields[0], "kinjected");
+3
View File
@@ -39,6 +39,9 @@ mod feedback;
mod mic;
mod session;
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
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
+40
View File
@@ -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>
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
</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>
</plist>
@@ -365,6 +365,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
@@ -399,6 +400,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
@@ -408,6 +408,7 @@ struct ContentView: View {
_ host: StoredHost, launchID: String? = nil,
allowTofu: Bool, requestAccess: Bool = false
) {
prepareWake(for: host)
model.connect(
to: host,
width: UInt32(clamping: width), height: UInt32(clamping: height),
@@ -426,6 +427,25 @@ struct ContentView: View {
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
/// 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
@@ -455,7 +475,9 @@ struct ContentView: View {
/// inside `connect`.)
private func connectDiscovered(_ d: DiscoveredHost) {
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)
if d.allowsTofu {
connect(host, allowTofu: true)
@@ -154,7 +154,14 @@ struct HomeView: View {
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
onForget: { store.forgetIdentity(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 {
@@ -86,6 +86,9 @@ struct HostCardView: View {
let onRemove: () -> Void
/// Open the experimental library browser nil (no menu item) unless the feature flag is on.
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 {
let m = CardMetrics.current
@@ -138,6 +141,9 @@ struct HostCardView: View {
if let 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 {
// 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.
@@ -26,9 +26,16 @@ struct StoredHost: Identifiable, Codable, Hashable {
/// 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.)
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 effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
var wakeMacs: [String] { macAddresses ?? [] }
}
extension StoredHost {
@@ -101,6 +108,16 @@ final class HostStore: ObservableObject {
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
/// 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).
@@ -31,6 +31,12 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
/// 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).
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
@@ -111,10 +117,15 @@ public final class HostDiscovery: ObservableObject {
var fp: String?
var pair: String?
var id: String?
var macs: [String] = []
if case let .bonjour(txt) = result.metadata {
fp = Self.entry(txt, "fp")
pair = Self.entry(txt, "pair")
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)
connections[key] = conn
@@ -129,7 +140,7 @@ public final class HostDiscovery: ObservableObject {
id: (id?.isEmpty == false) ? id! : name,
name: name, host: address, port: port.rawValue,
fingerprintHex: fp, requiresPairing: pair == "required",
allowsTofu: pair == "optional")
allowsTofu: pair == "optional", macAddresses: macs)
self.publish()
}
conn.cancel()
@@ -67,6 +67,53 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
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 {
private var handle: OpaquePointer?
/// Set by close() before it contends for the plane locks: the pullers see it at their
+34
View File
@@ -489,6 +489,40 @@ class Plugin:
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
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:
"""Fetch a paired host's game library via the flatpak client's headless
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
+6
View File
@@ -122,6 +122,12 @@ export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>
"set_settings",
);
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");
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
export const updateClient = callable<
+7 -1
View File
@@ -8,7 +8,7 @@
// and start it with RunGame. The wrapper then execs
// `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
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
@@ -219,6 +219,11 @@ export async function launchStream(
port: number,
opts: LaunchOpts = {},
): 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();
// 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).
@@ -240,6 +245,7 @@ export async function launchStream(
// 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.
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);
}
+3
View File
@@ -31,6 +31,9 @@ pipewire = "0.9"
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
# need the hidapi driver).
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"
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
+22
View File
@@ -116,6 +116,23 @@ pub fn run() -> glib::ExitCode {
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.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]`.
// 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") {
@@ -125,6 +142,11 @@ pub fn run() -> glib::ExitCode {
if let Some(target) = crate::cli::arg_value("--library") {
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);
// 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.
+41
View File
@@ -101,6 +101,14 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
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 {
name: addr.clone(),
addr,
@@ -108,9 +116,39 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
fp_hex: None,
pair_optional: false,
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
/// 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
@@ -138,6 +176,7 @@ pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> {
fp_hex: k.map(|k| k.fp_hex.clone()),
pair_optional: false,
launch: None,
mac: k.map(|k| k.mac.clone()).unwrap_or_default(),
},
k.is_some_and(|k| k.paired),
mgmt,
@@ -210,6 +249,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
),
pair_optional: true,
launch: None,
mac: Vec::new(),
};
let mock_advert =
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
@@ -221,6 +261,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
fp_hex: fp.to_string(),
pair: "required".to_string(),
mgmt_port: None,
mac: Vec::new(),
};
// What the self-capture renders: the main window, except for scenes that open their
+8
View File
@@ -22,6 +22,9 @@ pub struct DiscoveredHost {
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
/// library client then falls back to the well-known default.
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.
@@ -81,6 +84,11 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
fp_hex: val("fp"),
pair: val("pair"),
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) => {
+102 -10
View File
@@ -551,6 +551,14 @@ struct Worker<'a> {
/// switch / detach so a contact held at that moment doesn't stick. surface 0 = the legacy single
/// touchpad, 1/2 = a Steam left/right pad.
held_touches: std::collections::HashSet<(u8, u8)>,
/// 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 (surface1 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],
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
@@ -681,6 +689,24 @@ impl Worker<'_> {
}
*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).
for (surface, finger) in self.held_touches.drain() {
let rich = if surface == 0 {
@@ -709,6 +735,8 @@ impl Worker<'_> {
self.held_buttons.clear();
self.last_axis = [i32::MIN; 6];
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.
self.reset_chord();
@@ -789,26 +817,29 @@ impl Worker<'_> {
y: f32,
active: bool,
) {
let Some(c) = self.attached.as_ref() else {
let Some(c) = self.attached.clone() else {
return;
};
let multi = self
.open
.as_ref()
.filter(|(id, _)| *id == which)
.map(|(_, p)| p.touchpads_count() >= 2)
.unwrap_or(false);
let multi = self.is_multi_touchpad(which);
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
let rich = if multi {
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 {
pad: 0,
surface,
finger,
touch: active,
click: false,
x: (cx * 65535.0 - 32768.0) as i16,
y: (cy * 65535.0 - 32768.0) as i16,
// The pad's physical click is a separate BUTTON event (see forward_click) —
// carry the held state so a motion frame can't clear a click mid-press.
click: self.held_clicks[i],
x: wx,
y: wy,
pressure: 0,
}
} 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.
fn publish(&self) {
let mut list: Vec<PadInfo> = self
@@ -935,6 +1017,10 @@ impl Worker<'_> {
}
}
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 {
return;
};
@@ -945,6 +1031,10 @@ impl Worker<'_> {
}
}
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 {
return;
};
@@ -1158,6 +1248,8 @@ fn run(
last_axis: [i32::MIN; 6],
held_buttons: Vec::new(),
held_touches: std::collections::HashSet::new(),
surface_last: [(0, 0, false); 2],
held_clicks: [false; 2],
last_accel: [0; 3],
escape_tx: escape_tx.clone(),
disconnect_tx: disconnect_tx.clone(),
+42
View File
@@ -106,6 +106,9 @@ pub fn start_session_with(
}
let mode = resolve_mode(&app);
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 {
host: req.addr.clone(),
port: req.port,
@@ -125,6 +128,7 @@ pub fn start_session_with(
pin,
identity: app.identity.clone(),
connect_timeout: opts.connect_timeout,
force_software: force_software.clone(),
};
let inhibit = s.inhibit_shortcuts;
let show_stats = s.show_stats;
@@ -149,6 +153,7 @@ pub fn start_session_with(
inhibit,
show_stats,
frames: Some(frames),
force_software,
waiting: opts.waiting,
page: None,
};
@@ -198,6 +203,9 @@ struct SessionUi {
stop: Arc<AtomicBool>,
/// Decoded-frame receiver, handed to the stream page once on `Connected`.
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.
waiting: Option<adw::AlertDialog>,
page: Option<crate::ui_stream::StreamPage>,
@@ -259,6 +267,7 @@ impl SessionUi {
window: self.app.window.clone(),
connector,
frames: self.frames.take().expect("Connected delivered once"),
force_software: self.force_software.clone(),
clock_offset_ns,
escape_rx: self.app.gamepad.escape_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 {
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);
}
+4
View File
@@ -39,6 +39,10 @@ mod ui_stream;
mod ui_trust;
#[cfg(target_os = "linux")]
mod video;
#[cfg(target_os = "linux")]
mod video_gl;
mod wol;
#[cfg(target_os = "linux")]
fn main() -> gtk::glib::ExitCode {
+15
View File
@@ -43,6 +43,11 @@ pub struct SessionParams {
/// connection until the operator clicks Approve in its console (so this must exceed the
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
pub connect_timeout: Duration,
/// 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):
@@ -238,6 +243,7 @@ fn pump(
return;
}
};
let force_software = params.force_software.clone();
// 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
// thread (one puller per plane), blocking on the audio queue like the Apple client.
@@ -331,6 +337,15 @@ fn pump(
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
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
+32
View File
@@ -60,6 +60,11 @@ pub struct KnownHost {
/// most-recent card with the accent bar. `default` so pre-existing stores load.
#[serde(default)]
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)]
@@ -115,6 +120,10 @@ impl KnownHosts {
if entry.last_used.is_some() {
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 {
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(),
paired,
last_used: None,
mac: Vec::new(),
});
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
/// most-recent accent). No-op when the fingerprint isn't stored.
pub fn touch_last_used(fp_hex: &str) {
+37 -1
View File
@@ -29,6 +29,9 @@ pub struct ConnectRequest {
/// `("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.
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 {
@@ -314,6 +317,14 @@ fn rebuild(state: &Rc<State>) {
state.saved_flow.remove_all();
for k in &known.hosts {
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());
state
.saved_flow
@@ -421,6 +432,7 @@ fn saved_card(
// connect; TOFU eligibility is irrelevant.
pair_optional: false,
launch: None,
mac: k.mac.clone(),
};
// Presence pip + spelled-out state, then the trust pill.
@@ -492,11 +504,24 @@ fn saved_card(
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));
let menu = gio::Menu::new();
menu.append(Some("Pair with PIN…"), Some("card.pair"));
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
// item is offered on every saved card — an unpaired host answers with the friendly
// "not paired" error state rather than the entry hiding itself.
@@ -521,7 +546,16 @@ fn saved_card(
overlay.add_controller(right_click);
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
}
@@ -539,6 +573,7 @@ fn discovered_card(
// required/empty means mandatory PIN.
pair_optional: a.pair == "optional",
launch: None,
mac: a.mac.clone(),
};
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
@@ -674,6 +709,7 @@ fn add_host_dialog(state: &Rc<State>) {
// Manual entry carries no advertised policy — never eligible for TOFU.
pair_optional: false,
launch: None,
mac: Vec::new(),
});
});
}
+68
View File
@@ -111,6 +111,10 @@ pub struct StreamPageArgs {
pub window: adw::ApplicationWindow,
pub connector: Arc<NativeClient>,
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
/// clock to express paintable-set time in the host's capture clock (present latency).
pub clock_offset_ns: i64,
@@ -253,6 +257,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
window,
connector,
frames,
force_software,
clock_offset_ns,
escape_rx,
disconnect_rx,
@@ -291,6 +296,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
spawn_frame_consumer(
&w.picture,
frames,
force_software,
clock_offset_ns,
presented.clone(),
hdr.clone(),
@@ -584,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(
picture: &gtk::Picture,
frames: async_channel::Receiver<DecodedFrame>,
force_software: Arc<AtomicBool>,
clock_offset_ns: i64,
presented_stats: Rc<PresentedStats>,
hdr: Rc<Cell<bool>>,
@@ -599,6 +629,11 @@ fn spawn_frame_consumer(
// (SDR↔HDR flip) just rebuilds once.
let mut yuv_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 {
// Window samples (µs): end-to-end capture→displayed (host-clock corrected) and
// the client-local display stage decoded→displayed.
@@ -646,6 +681,39 @@ fn spawn_frame_consumer(
picture.set_paintable(Some(&tex));
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) => {
let mut b = gdk::DmabufTextureBuilder::new()
.set_display(&picture.display())
+29
View File
@@ -187,6 +187,12 @@ impl Decoder {
.ok()
.filter(|v| !v.is_empty())
.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" {
match VaapiDecoder::new(codec_id) {
Ok(v) => {
@@ -220,6 +226,21 @@ impl Decoder {
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
/// one-in/one-out). A software decode error after packet loss is survivable — log
/// upstream and keep feeding. A VAAPI error re-requests an IDR and retries the hardware
@@ -456,6 +477,14 @@ impl VaapiDecoder {
(*ctx).get_format = Some(pick_vaapi);
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
(*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());
if r < 0 {
let mut ctx = ctx;
+662
View File
@@ -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 800p4K 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(1Kr)·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);
}
}
+24
View File
@@ -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"),
}
}
+1
View File
@@ -245,6 +245,7 @@ fn connect_with(
port: target.port,
fp_hex: trust::hex(&fingerprint),
paired: persist_paired,
mac: target.mac.clone(),
});
let _ = k.save();
}
+33 -8
View File
@@ -13,6 +13,7 @@ use windows_reactor::*;
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
const MENU_CONNECT: &str = "Connect";
const MENU_SPEED: &str = "Test network speed\u{2026}";
const MENU_WAKE: &str = "Wake host";
const MENU_RENAME: &str = "Rename\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,
fp_hex: Some(k.fp_hex.clone()),
pair_optional: false,
mac: k.mac.clone(),
};
let online = hosts
.iter()
.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 (svc, target) = (props.svc.clone(), target.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()
.tooltip("More options")
.automation_name("More options")
.menu_flyout(vec![
menu_item(MENU_CONNECT),
menu_item(MENU_SPEED),
menu_item(MENU_RENAME),
menu_separator(),
menu_item(MENU_FORGET),
])
.menu_flyout({
let mut items = vec![menu_item(MENU_CONNECT), menu_item(MENU_SPEED)];
// Offer an explicit wake only when the host is offline and we have a MAC.
if can_wake {
items.push(menu_item(MENU_WAKE));
}
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() {
MENU_CONNECT => {
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
}
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
MENU_SPEED => {
*svc.ctx.shared.target.lock().unwrap() = target.clone();
// 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 },
),
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));
@@ -406,6 +429,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
port: h.port,
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
pair_optional: h.pair == "optional",
mac: h.mac.clone(),
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let (badge, kind) = if h.pair == "required" {
@@ -486,6 +510,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
port,
fp_hex: None,
pair_optional: false,
mac: Vec::new(),
},
&ss,
&st,
+3
View File
@@ -68,6 +68,9 @@ pub(crate) struct Target {
pub(crate) port: u16,
pub(crate) fp_hex: Option<String>,
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
+1
View File
@@ -50,6 +50,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
port: target3.port,
fp_hex: trust::hex(&fp),
paired: true,
mac: target3.mac.clone(),
});
let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st);
+8
View File
@@ -15,6 +15,9 @@ pub struct DiscoveredHost {
pub fp_hex: String,
/// Pairing requirement: `"required"` or `"optional"`.
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
@@ -63,6 +66,11 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
port: info.get_port(),
fp_hex: val("fp"),
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() {
break; // UI gone — stop browsing
+4
View File
@@ -43,6 +43,9 @@ mod trust;
#[cfg(windows)]
mod video;
#[cfg(windows)]
mod wol;
#[cfg(windows)]
fn main() {
// 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,
fp_hex: trust::hex(&fp),
paired: true,
mac: Vec::new(),
});
let _ = k.save();
tracing::info!(fp = %trust::hex(&fp), "paired");
+31
View File
@@ -57,6 +57,11 @@ pub struct KnownHost {
pub fp_hex: String,
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
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)]
@@ -106,12 +111,38 @@ impl KnownHosts {
h.addr = entry.addr;
h.port = entry.port;
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 {
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
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)]
+24
View File
@@ -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"),
}
}
+4
View File
@@ -38,6 +38,10 @@ thiserror = "2"
tracing = { version = "0.1", default-features = false, features = ["std"] }
rand = "0.9"
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 }
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
+54
View File
@@ -183,6 +183,60 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
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).
/// Returns NULL on error.
///
+4 -1
View File
@@ -39,6 +39,7 @@ pub mod quic;
pub mod session;
pub mod stats;
pub mod transport;
pub mod wol;
pub use config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
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);
/// 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;
+192
View File
@@ -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());
}
}
}
+4
View File
@@ -21,6 +21,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-log = "0.2"
axum = "0.8"
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"] }
rsa = "0.9"
sha2 = { version = "0.10", features = ["oid"] }
+15
View File
@@ -15,6 +15,9 @@
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
//! - `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 mdns_sd::{ServiceDaemon, ServiceInfo};
@@ -63,6 +66,18 @@ pub fn advertise_native(
if let Some(mgmt) = mgmt_port {
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)
.context("build native mDNS ServiceInfo")?;
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:
/// **`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
/// 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.
fn open_transport(idx: u8) -> Result<DeckTransport> {
warn_if_inputplumber();
use crate::inject::{steam_gadget, steam_usbip};
// 1. raw_gadget — the validated SteamOS fast-path (default on there).
if steam_gadget::gadget_preferred() {
+1
View File
@@ -22,6 +22,7 @@ mod audio;
mod capture;
mod config;
mod discovery;
mod wol;
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
// keeps the `crate::*` module names flat (every existing path is unchanged).
#[cfg(target_os = "linux")]
+16 -1
View File
@@ -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
/// `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.
///
/// 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")]
fn physical_steam_controller_present() -> bool {
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:") {
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()) {
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,
}
})
+114
View File
@@ -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
}
+8 -2
View File
@@ -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`
version handshake + the `verify_is_wudfhost` image check.
* **`WDA_EXCLUDEFROMCAPTURE` windows are visible.** IDD-push taps the *present* side, not the
*capture* side, so windows that exclude themselves from capture still appear in the stream — true
of every virtual-display streaming stack. Untested on our lab box; treat as expected behavior.
*capture* side, so windows that exclude themselves from capture still appear in the stream. This is
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
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
+10
View File
@@ -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
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
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.
+20 -1
View File
@@ -17,7 +17,9 @@
//
// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
// 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).
#define PUNKTFUNK_HIDOUT_LED 1
@@ -804,6 +806,23 @@ extern "C" {
// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
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).
// Returns NULL on error.
//
+5 -6
View File
@@ -68,12 +68,11 @@ finish-args:
# PulseAudio shim — so it needs the real `pipewire-0` socket in the sandbox. With only
# --socket=pulseaudio the sandbox has just `pulse/native`, no `pipewire-0`, and playback +
# mic both die with "pw connect (is PipeWire running in this session?)" (observed live on the
# Deck in Gaming Mode). --socket=pipewire is the canonical grant; --filesystem=xdg-run/
# pipewire-0 binds the same socket portably (validated on-Deck: it makes pipewire-0 appear in
# the sandbox where --socket=pipewire's CLI validation was flaky). Neither needs the
# camera/portal dance (that's only for camera nodes). --socket=pulseaudio stays as a fallback
# for any pulse-only path. ---
- --socket=pipewire
# 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
# --- network: QUIC control + UDP data plane + mDNS discovery (_punktfunk._udp) ---