Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbf3fea0c8 | |||
| c52ae119e1 | |||
| 5d7aabe8f0 | |||
| f204a89cef | |||
| 24fa018c70 | |||
| 51a6ca7e02 | |||
| b9fde03f1e | |||
| efb1ba26d7 | |||
| 1320e3dc66 | |||
| 1be83575b6 | |||
| 4d1d20f832 | |||
| 6e875fea44 | |||
| 4f3cd24036 |
@@ -0,0 +1,9 @@
|
||||
# Shown on the "new issue" chooser so security reports go to the private channel, not a public issue.
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 🔒 Report a security vulnerability
|
||||
url: https://git.unom.io/unom/punktfunk/src/branch/main/SECURITY.md
|
||||
about: >-
|
||||
Found a security issue? Please report it privately by email to security@punktfunk.com — do not
|
||||
open a public issue, so other users aren't exposed before a fix ships. See SECURITY.md for the
|
||||
full policy.
|
||||
@@ -31,3 +31,6 @@ xcuserdata/
|
||||
# Python bytecode (e.g. clients/android/ci tooling)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Claude Code project instructions — local to each dev box, not part of the repo.
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,585 +0,0 @@
|
||||
# CLAUDE.md — punktfunk
|
||||
|
||||
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
|
||||
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
|
||||
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
|
||||
|
||||
## Where the work stands
|
||||
|
||||
- **Core (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
|
||||
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
|
||||
regression-tested (`a913042`).
|
||||
- **GameStream host: working end-to-end with a stock Moonlight client.** Validated live
|
||||
on this box: pairing (persists across restarts), serverinfo/applist (app catalog from
|
||||
`~/.config/punktfunk/apps.json` → each entry picks a compositor + nested command), RTSP, ENet
|
||||
control, audio, and video at the **client's native resolution and refresh** — the host
|
||||
creates a per-session virtual output via per-compositor `VirtualDisplay` backends:
|
||||
**KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via
|
||||
custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs
|
||||
gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus
|
||||
`RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy),
|
||||
**Sway/wlroots** (`swaymsg create_output` + custom mode, xdpw portal capture with a
|
||||
managed chooser config; validated live on sway 1.11, zero-copy).
|
||||
Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf →
|
||||
EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC
|
||||
split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic
|
||||
freeze), encode|send thread split with `sendmmsg` batching. Stable 240 fps at 5120×1440.
|
||||
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
|
||||
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
|
||||
back-channel; validated live — pad created/destroyed with the session). Management REST API +
|
||||
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
|
||||
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
|
||||
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
|
||||
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
|
||||
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
|
||||
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
|
||||
boundary, and finished captures are saved as on-disk recordings
|
||||
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
|
||||
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
|
||||
**Web-console log view** (`log_capture.rs`): a `tracing` layer tees DEBUG-and-up (independent of
|
||||
`RUST_LOG`) into a 4096-entry in-memory ring, served cursor-paged at `GET /api/v1/logs`
|
||||
(bearer-only) → the console's **Logs** page (follow/pause · level filter · search). The Windows
|
||||
gamepad drivers now stamp attach/heartbeat marks into their shm sections and the host's
|
||||
`DriverAttach` watcher turns silence into a one-shot diagnosis WARN (driver-store check + CM
|
||||
devnode problem code) — failure-mode table: [`design/gamepad-driver-health.md`](design/gamepad-driver-health.md).
|
||||
The Android client gained Settings → **Connected controllers** (device list + VID/PID + resolved
|
||||
pad type + live input test) for the client end of the same chain. *Log view + driver health:
|
||||
Linux-tested; Windows/Android sides CI/device-validation pending.*
|
||||
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
|
||||
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
|
||||
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
|
||||
(inexpressible in GameStream), host creates the native virtual output at the client's
|
||||
requested mode. `punktfunk1-host` is a **persistent listener** (sessions back to back;
|
||||
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
|
||||
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
|
||||
audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:**
|
||||
host serves its persistent identity (`~/.config/punktfunk/cert.pem`, shared with GameStream
|
||||
pairing) and logs the SHA-256 fingerprint; clients pin it, established by a **SPAKE2 PIN pairing
|
||||
ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
|
||||
attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
|
||||
hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
|
||||
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
|
||||
clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every
|
||||
other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing
|
||||
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
|
||||
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
|
||||
default; `--allow-tofu`/`--open` accept unpaired clients).
|
||||
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
|
||||
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
|
||||
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
|
||||
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
|
||||
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
|
||||
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
|
||||
(validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms**
|
||||
capture→…→reassembled; audio measured live (~200 pkts/s). A **wall-clock skew handshake**
|
||||
(`ClockProbe`/`ClockEcho`, 8 NTP rounds after `Start`, `clock_offset_ns`) aligns the client to the
|
||||
host clock, so that latency is now valid **cross-machine** (`skew_corrected=true`) — measured GNOME
|
||||
box → dev box over the LAN: **p50 1.30 ms** (the −1.57 ms inter-box clock offset removed).
|
||||
`punktfunk-probe` is the
|
||||
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
|
||||
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
|
||||
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
|
||||
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
|
||||
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
||||
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
||||
env > uinput Xbox 360. Backends: **Xbox 360** (uinput on Linux / the pf-xusb UMDF driver on
|
||||
Windows), **Xbox One/Series** (the same
|
||||
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
|
||||
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
||||
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
||||
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
||||
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
|
||||
(UMDF minidriver)** backend — `inject/windows/dualsense_windows.rs` + `inject/windows/dualshock4_windows.rs`, one
|
||||
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
|
||||
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
|
||||
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
|
||||
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
|
||||
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
|
||||
(`packaging/windows/drivers/pf-xusb/`, `inject/windows/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
|
||||
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
|
||||
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
|
||||
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
|
||||
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via
|
||||
`punktfunk-host.exe driver install --gamepad`. The gamepad drivers' **business logic is 100 % safe
|
||||
Rust**: every raw shared-memory / sealed-channel / WDF-request operation lives behind
|
||||
`pf-umdf-util` (the audited unsafe layer — `section::MappedView` checked accessors, the
|
||||
`#![forbid(unsafe_code)]` `channel::ChannelClient` state machine, `wdf::Request` tokens), so a
|
||||
memory-safety bug can only live in that one small crate. The whole drivers workspace is lint-gated
|
||||
(`deny(unsafe_op_in_unsafe_fn)` + `deny(clippy::undocumented_unsafe_blocks)`) with a
|
||||
`cargo clippy -D warnings` step in `windows-drivers.yml`; pf-vdisplay stays FFI-bound (D3D11/IddCx)
|
||||
but every `unsafe {}` there now carries a `// SAFETY:` proof (unsafe-audited, not unsafe-free).
|
||||
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
|
||||
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
||||
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
||||
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
||||
the remaining piece.)
|
||||
- **Windows host: implemented and shipping (all-vendor, x64-only, Windows 11 22H2+).** The OS floor
|
||||
is HARD: pf-vdisplay is built against IddCx 1.10 (1.10 stub + HDR `*2` DDIs + FP16 caps, no runtime
|
||||
downgrade) — on Windows 10 (incl. LTSC) / Win11 21H2 the driver installs but the device fails start
|
||||
with Code 10 `STATUS_DEVICE_POWER_FAILURE` (field-reported 2026-07); the installer gates on
|
||||
`MinVersion=10.0.22621`. `#[cfg(windows)]` backends
|
||||
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
|
||||
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
|
||||
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`). The host↔driver frame
|
||||
ring is a **sealed channel** (proto v2, `design/idd-push-security.md`): all shared objects
|
||||
UNNAMED, handles `DuplicateHandle`d into the driver's WUDFHost and delivered as values over
|
||||
`IOCTL_SET_FRAME_CHANNEL` (SY+BA-only control device) — only the two endpoint processes can ever
|
||||
reach a frame (DDA's isolation property in user mode; adopt-on-success handle-ownership contract,
|
||||
newest-delivery-wins re-attach). *Sealed channel: CI-pending + on-glass revalidation pending.*
|
||||
The **gamepad SHM channels are sealed the same way** (gamepad proto v2,
|
||||
`design/gamepad-channel-sealing.md`): the pad DATA sections (`XusbShm`/`PadShm`, now with a
|
||||
driver-validated `pad_index`) are unnamed + handle-duplicated into the pad WUDFHost
|
||||
(`gamepad_raii.rs` `PadChannel`); since the HID minidrivers have no control device, the handshake
|
||||
runs over a tiny named bootstrap mailbox (`Global\pf…-boot-<i>`, pid + handle value only — tampering
|
||||
is DoS-bounded). *Sealed pad channel: needs both pad drivers redeployed with the host, physical-pad
|
||||
validation pending.* GPU encode (NVENC
|
||||
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
|
||||
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
|
||||
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
|
||||
the Windows client derives it from the scancode, NOT the layout-resolved `vkCode`) — the Windows
|
||||
injector resolves them via a fixed table mirroring the Linux `vk_to_evdev` (never through a
|
||||
keyboard layout: the SYSTEM service thread's layout re-reads positions as characters — the
|
||||
German y↔z / ö→ü scramble), while GameStream/Moonlight VKs are layout-semantic
|
||||
(`KEY_FLAG_SEMANTIC_VK`, resolved under the foreground app's layout, Sunshine's model). Linux
|
||||
renders positions under the session compositor's layout (libei) or the virtual keyboard's
|
||||
uploaded keymap (Sway/wlroots — honors `XKB_DEFAULT_LAYOUT` et al., default US); the Android
|
||||
client reads `KeyEvent.scanCode` first so a user-selected physical-keyboard layout can't
|
||||
re-map keycodes semantically. Ships as a **signed
|
||||
Inno Setup installer** that registers a `LocalSystem` SCM service launching into the interactive
|
||||
session for secure-desktop (UAC/lock-screen) capture (`windows/service.rs`), bundles the
|
||||
pf-vdisplay driver + the FFmpeg DLLs (+ VB-CABLE for the virtual mic), and is published by
|
||||
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
|
||||
`PUNKTFUNK_ENCODER=auto` (the host.env default) reads the **selected render adapter's** vendor →
|
||||
**NVENC** (NVIDIA, direct SDK, `encode/windows/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
|
||||
(`encode/windows/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
|
||||
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
|
||||
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
|
||||
probed per-GPU on AMF/QSV (`windows_codec_support` → `serverinfo`, AV1 gated; cached per selected
|
||||
GPU). **Multi-GPU is first-class** (`gpu.rs`): GPU inventory + a persisted auto/manual preference
|
||||
(`<config>/gpu-settings.json`, stored by stable PCI identity — LUIDs are per-boot) exposed over
|
||||
`GET /api/v1/gpus` + `PUT /api/v1/gpus/preference` and a web-console GPU card (Host page: list,
|
||||
Automatic/Prefer, "In use · backend" badge). One selection — precedence **console preference >
|
||||
`PUNKTFUNK_RENDER_ADAPTER` > max VRAM**, graceful fallback when the preferred GPU is absent —
|
||||
feeds `win_adapter::resolve_render_adapter_luid` (capture ring + IddCx render pin), the encoder
|
||||
vendor auto-detect (previously DXGI adapter 0 — wrong on hybrid boxes like NVIDIA dGPU + Intel
|
||||
Arc iGPU), and the NVENC 4:4:4 probe; a preference change applies to the next session. On Linux a
|
||||
matched manual preference picks the VAAPI render node / NVENC-vs-VAAPI auto choice (auto mode
|
||||
unchanged). *Implemented + unit-tested; not yet on-glass validated on the hybrid box.* **HDR (10-bit)**: WGC
|
||||
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
|
||||
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
|
||||
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
|
||||
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
|
||||
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
|
||||
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
|
||||
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
|
||||
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
|
||||
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
|
||||
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
|
||||
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
|
||||
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
|
||||
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
|
||||
- **Status tray (`crates/punktfunk-tray`, Windows + Linux).** A small per-user companion binary
|
||||
showing the host service state at a glance (running / stopped / starting / degraded / failed +
|
||||
streaming session in the tooltip) with one-click actions: open web console, approve-pairing
|
||||
shortcut, start/stop/restart, open logs, exit. Status precedence is **service manager first**
|
||||
(SCM / systemd user unit — a port-squatter can't fake Running), then the new **loopback-only
|
||||
unauthenticated** `GET /api/v1/local/summary` (counts/booleans only — no PINs/fingerprints/names;
|
||||
gated in `require_auth` by peer address, needed because `mgmt-token`/`cert.pem` are
|
||||
SYSTEM/Admins-DACL'd on Windows so a non-elevated tray cannot bearer-auth). Windows:
|
||||
`#![windows_subsystem = "windows"]` hidden-window + `Shell_NotifyIconW` (per-session `Local\`
|
||||
mutex, TaskbarCreated re-add, `--quit` for the uninstaller), actions elevate per click via
|
||||
`ShellExecuteW "runas"` on `punktfunk-host.exe service start|stop|restart` (new `service restart`
|
||||
subcommand: stop → wait Stopped → start); installed by the Inno `trayicon` task (HKLM Run key).
|
||||
Linux: ksni (SNI over zbus, `async-io`+`blocking` features), `systemctl --user` actions (no
|
||||
polkit), `/etc/xdg/autostart` entry whose `--autostart` self-gates (silent exit unless
|
||||
`~/.config/punktfunk` exists or the unit is enabled); deb/rpm/arch ship binary + autostart +
|
||||
hicolor icons. Icons generated by `scripts/gen-tray-icons.py` (pure-stdlib; committed .ico/.png,
|
||||
brand lens + status dot). *Linux live-validated on the headless KDE session (SNI registration,
|
||||
stop/start transitions, menu-driven start, dbusmenu layout); Windows code MSVC-cross-type-checked
|
||||
+ clippy-clean but real Windows CI build + on-glass validation pending.*
|
||||
|
||||
## What's left
|
||||
|
||||
1. **Native clients — decode + present: macOS stage 1 done, first light achieved
|
||||
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
|
||||
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
|
||||
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
|
||||
EIS. The app speaks the full ABI v2 trust surface: Keychain-persisted client identity
|
||||
presented on every connect, SPAKE2 PIN pairing UI (host-card context menu + the trust
|
||||
prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. **Gamepads (2026-06-11):**
|
||||
controller discovery + selection in Settings (`GamepadManager` — exactly one pad
|
||||
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
|
||||
controller, user-overridable), capture incl. DualSense touchpad/motion
|
||||
(`GamepadCapture`/`GamepadWire`; while streaming, EVERY element's
|
||||
`preferredSystemGestureState` is claimed `.disabled` — share/create reaches the host as
|
||||
select instead of screenshotting locally, PS/Home reaches the host as guide/`BTN_MODE` =
|
||||
the Steam-overlay button — restored `.enabled` on unbind), feedback rendering (rumble → CoreHaptics; lightbar /
|
||||
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
||||
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
||||
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
||||
motion sign/scale derived, not yet live-verified. **Rumble renderer rewritten
|
||||
(2026-07-02, `RumbleRenderer.swift`)** around "rumble is idempotent state, divergence
|
||||
must be bounded": the old per-datagram infinite-duration CoreHaptics players could leak
|
||||
one dropped async `stop` into a forever-buzzing motor (the stuck-rumble-after-menu bug)
|
||||
— now finite self-expiring segments with seamless engine-timeline re-arm, newest-wins
|
||||
dry drain of the 0xCA plane (was 1 datagram/8 ms), dedupe of the host's 500 ms state
|
||||
refreshes, zero-immediate/ramp-throttled rebakes, escalation to `engine.stop()` on a
|
||||
throwing player stop, and a 1.6 s staleness watchdog (`Policy.session`; the settings
|
||||
test panel uses `.manual` = hold). Controller engines use **plain `makePlayer` — never
|
||||
`makeAdvancedPlayer`**: the controller haptics server (gamecontrollerd) advertises
|
||||
`adv players: 0`, and iOS 27 beta 2 hard-drops advanced-player loads (XPC decode fault →
|
||||
CoreHaptics -4811/4097, rumble silently dead). Unit-tested (`RumbleTuningTests`);
|
||||
stuck-rumble repro on-glass revalidation pending. **Gamepad UI (iOS/iPadOS + macOS,
|
||||
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
|
||||
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
|
||||
Host tile (A connect · Y library · X settings · B back), a controller-navigable
|
||||
settings screen (vertical `GamepadMenuList`, left/right steps values), an add-host
|
||||
flow with an on-screen controller keyboard (`GamepadKeyboard` — no touch needed
|
||||
anywhere), and the coverflow library, all over an animated aurora backdrop
|
||||
(`GamepadScreenBackground`, TimelineView-driven drifting blobs — pure SwiftUI ON
|
||||
PURPOSE: a .metal lib only reliably bundles in one of the two build systems (SPM vs
|
||||
xcodeproj synced folders) these sources compile under). Input is the polled
|
||||
`GamepadMenuInput` (handlers don't fire outside a stream; on (re)start it SNAPSHOTS
|
||||
held buttons so a handoff press never double-fires), haptics dual-channel (device +
|
||||
`MenuHaptics` on the pad). macOS: same screens, settings/add-host as sheets (no
|
||||
fullScreenCover), NSScreen-based mode lists, scroll indicators `.never` (macOS
|
||||
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
|
||||
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
|
||||
the mode without a pad). Controller-in-hand on-glass validation still pending on all
|
||||
platforms. **Touch input (iOS/iPadOS, 2026-07-02):** a 3-way model in Settings —
|
||||
**Trackpad** (default; the Android client's gesture vocabulary ported 1:1 in
|
||||
`Input/TouchMouse.swift`: tap=click · two-finger tap=right-click · two-finger drag=scroll ·
|
||||
tap-then-drag=held drag · three-finger tap=HUD toggle, relative ballistics with the same
|
||||
px-based acceleration curve), **Direct pointer** (cursor jumps to the finger), **Touch
|
||||
passthrough** (the previous always-on behavior — real wire touches). Latched per gesture
|
||||
from `DefaultsKey.touchMode`; not yet on-glass validated. Tests: `swift test` in
|
||||
`clients/apple` (unit + real-codec round trip),
|
||||
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
|
||||
includes the pairing ceremony + `--require-pairing` gate),
|
||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter is now the DEFAULT**
|
||||
(stage-1 is the Metal-unavailable / DEBUG fallback): explicit `VTDecompressionSession` decode →
|
||||
`CAMetalLayer`, presented from the hosting view's **main-runloop `CADisplayLink`** (`renderTick` pops
|
||||
the newest ready frame per vsync; macOS `displaySyncEnabled = false` is the real fullscreen-judder fix,
|
||||
~11 ms p50). *(An off-main `CAMetalDisplayLink` and an off-main blocking-render present thread were
|
||||
both tried and reverted — both measured slower on macOS and iPad.)* **HDR fixed**
|
||||
(`design/apple-stage2-presenter.md`): the "too bright" bug was a missing reference-white anchor — the
|
||||
fix keeps the PQ-passthrough shader and adds `CAEDRMetadata.hdr10(…, opticalOutputScale: 203)` +
|
||||
`wantsExtendedDynamicRangeContent` on the layer (on all platforms; the old `#if os(macOS)` guard left
|
||||
iOS/tvOS EDR half-engaged), routing the 0xCE mastering metadata to the layer (via `setHdrMeta`) instead
|
||||
of a never-composited source buffer. **Mid-session SDR↔HDR** is handled: `render` reconciles the layer
|
||||
per-frame from the decoded `frame.isHDR` (per-mode pixel format `bgra8`/`rgba16Float`), so a game
|
||||
entering HDR mid-stream just reconfigures (last 0xCE grade cached + re-applied; pump drains 0xCE
|
||||
unconditionally). **4:4:4 added**: decode format is a 2×2 `(chroma, HDR)` matrix
|
||||
(`420v/x420/444v/x444`, all biplanar so the shaders are unchanged), advertised (`VIDEO_CAP_444`) only
|
||||
behind a **hardware-required `VTDecompressionSession` probe** (`Stage444Probe`, validated on M3) with a
|
||||
Settings opt-out + a bounded pump backstop for an undecodable 4:4:4 session. *HDR brightness + 4:4:4
|
||||
still need on-glass validation (Windows-HDR / `PUNKTFUNK_444` host).* Next: glass-to-glass numbers via
|
||||
`tools/latency-probe`.
|
||||
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
|
||||
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
|
||||
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
|
||||
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
|
||||
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
|
||||
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
||||
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
|
||||
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
|
||||
service (pad pin UI, auto type from the physical pad, DualSense touchpad/motion 0xCC +
|
||||
raw-DS5-effects trigger/player-LED replay — needs a physical pad to live-verify), mic
|
||||
uplink (validated live), per-host speed test, compositor pref, native-display mode
|
||||
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
|
||||
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
|
||||
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
|
||||
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode. **First AMD test
|
||||
(Steam Deck) hit a green-screen bug, fixed:** FFmpeg's VAAPI export uses
|
||||
`SEPARATE_LAYERS`, so NV12 arrives as two single-plane layers (R8 luma + GR88 chroma,
|
||||
one shared fd); the mapper took `layers[0]` only → GTK got a luma-only R8 texture, chroma
|
||||
read as 0 → green field / red whites. Fix derives the combined fourcc from the decoder
|
||||
`sw_format` (→ `DRM_FORMAT_NV12`) and flattens all planes across all layers (mpv's
|
||||
pattern); a first-frame descriptor dump logs the real layout. Awaiting Steam Deck
|
||||
reconfirm. Next: the stage-2 raw-Wayland
|
||||
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
|
||||
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
|
||||
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
|
||||
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
|
||||
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
|
||||
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain**, presented from a
|
||||
**dedicated render thread** (`render.rs`, 2026-07-02 rewrite — presenting never touches or is
|
||||
stalled by the XAML thread): frame-latency-waitable swapchain + `SetMaximumFrameLatency(1)`
|
||||
(≤1 queued present, newest-wins drain after the wait, so a stream faster than the display drops
|
||||
backlog before any GPU work), **HiDPI-correct** (pixel-sized buffers + `SetMatrixTransform`
|
||||
96/DPI — DIP-sized buffers were blurry at 125/150 %), Contain-fit letterbox, WARP fallback.
|
||||
**FFmpeg decode with a D3D11VA hardware path on all vendors** (`gpu.rs` shares one D3D11 device
|
||||
between decoder + presenter, adapter picked by console pref `PUNKTFUNK_ADAPTER` > the window's
|
||||
monitor's adapter > default; `PUNKTFUNK_D3D_DEBUG=1` adds the debug layer): the decode pool is
|
||||
**decoder-only bind, sized/aligned by libavcodec itself** (get_format returns `AV_PIX_FMT_D3D11`
|
||||
and lets `hw_device_ctx` drive — three hand-built-frames-context strikes are why: NVIDIA rejects
|
||||
`DECODER|SHADER_RESOURCE` arrays, `BindFlags=0` fails texture creation, and Intel rejects
|
||||
non-128-aligned HEVC surfaces at the first `SubmitDecoderBuffers`), a DXVA **profile probe**
|
||||
before the hwdevice commits software-vs-hardware up front (no burned first IDR), and the
|
||||
presenter copies the decoded slice with ONE display-size-boxed `CopySubresourceRegion` (a planar
|
||||
slice is a single subresource in D3D11 — the old two-copy D3D12-style code silently no-opped =
|
||||
the black screen) into its sampleable NV12/P010 texture → per-plane SRVs + YUV→RGB shaders
|
||||
(NV12/BT.709, P010/BT.2020-PQ). **Software CPU decode is the fallback** (auto-selected,
|
||||
`DecoderPref` override, mid-session demotion + keyframe re-request) and now feeds the SAME
|
||||
shaders (swscale → NV12/P010 planes → two dynamic plane textures) so hw/sw colour math is
|
||||
identical. **HDR10**: the client advertises 10-bit/HDR (Settings toggle, gated on an HDR
|
||||
display), detects PQ in-band (`transfer == SMPTE2084`), and flips the swapchain to
|
||||
`R10G10B10A2` + ST.2084 with HDR10 metadata (0xCE mastering metadata plumbed). **WASAPI** render
|
||||
+ mic capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full
|
||||
trust surface — all **in-app**: a polished WinUI shell (host tiles w/ monogram + status pills,
|
||||
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
|
||||
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
|
||||
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **Live-validated 2026-07-02
|
||||
on the hybrid laptop (Intel Arc Pro iGPU + RTX 3500 Ada) against the local Windows host**:
|
||||
D3D11VA hardware decode 60 fps on BOTH vendors (headless, `PUNKTFUNK_ADAPTER`-forced; NVIDIA
|
||||
0.2 ms decode, Intel 0.2 ms), software path, and the GUI on glass (real decoded desktop pixels,
|
||||
GPU-decode HUD chip, ~18 ms capture→decoded p50 over loopback — dominated by the host's 60 Hz
|
||||
virtual-display capture cadence). HDR-on-glass still pending. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
|
||||
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
|
||||
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
|
||||
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
|
||||
**cross-compiled off the one x64 runner** (no ARM64 runner; the x64 MSVC toolset's ARM64 cross
|
||||
compiler + a per-arch `FFMPEG_DIR` ARM64 tree, SDL3/libopus build-from-source cross-compile
|
||||
cleanly), and both ship as signed MSIX (`windows-msix.yml` matrix → `..._x64.msix`/`..._arm64.msix`,
|
||||
verified: ARM64 binaries + manifest arch). **windows-reactor is unpublished** (git
|
||||
dep pinned to commit `a4f7b2cb`, bumped 2026-07-02 from `b4129fcc` for `on_pointer_entered`/
|
||||
`on_pointer_exited` hover events — mechanical renames only: `SymbolGlyph`→`Symbol`,
|
||||
`placeholder`→`placeholder_text`, TextBox `on_changed`→`on_text_changed`, ToggleSwitch
|
||||
`on_changed`→`on_toggled`, `on_menu_item_clicked`→`on_item_clicked`, SwapChainPanel
|
||||
`on_ready`→`on_mounted`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies with
|
||||
`set_swap_chain`). New-model runtime staging: reactor has NO build.rs anymore — the app's own
|
||||
`build.rs` calls `windows_reactor_setup::as_framework_dependent()` (same-rev build-dep, stages
|
||||
the bootstrap DLL + resources.pri that pack-msix expects) and `main` calls
|
||||
`windows_reactor::bootstrap()` before `App` (packaged MSIX: a no-op, the manifest's
|
||||
`Microsoft.WindowsAppRuntime.2` dependency resolves the runtime). `CARGO_WORKSPACE_DIR` is no
|
||||
longer required (harmless where still set). Gotcha: `CARGO_HOME` must be an ASCII path
|
||||
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. **Parity/cleanup
|
||||
batch (2026-07-02)**: `app.rs` split into per-screen `app/` modules (mod=root/router · hosts ·
|
||||
connect · pair · speed · settings · licenses · stream · style; thread-driven state lives in ROOT
|
||||
`use_async_state` and flows down as props — a child's own async-state write does NOT re-render it);
|
||||
"Native display" now resolves the real monitor mode at connect (`MonitorFromWindow` →
|
||||
`EnumDisplaySettingsW`, was hardcoded 1080p60); per-host **speed test** (saved-host card button +
|
||||
`--headless --speed-test`, probe burst → recommended ≈70 % bitrate applied in one tap; bitrate
|
||||
setting is now a free-form NumberBox); **forget host** (ContentDialog confirm →
|
||||
`KnownHosts::remove_by_fp`); settings gained forwarded-controller picker + gamepad type + host
|
||||
compositor + capture-system-shortcuts — the previously-dead `Settings.compositor`/
|
||||
`inhibit_shortcuts` are now honored (off = Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally);
|
||||
**click-to-recapture** after a Ctrl+Alt+Shift+Q release with the HUD hint tracking capture state;
|
||||
input hook caches lock geometry (no per-move `GetClientRect`), audio jitter-ring trims via
|
||||
`drain`. Validated on the bare-metal RTX box: `--discover` (3 live LAN hosts), synthetic-host
|
||||
loopback E2E (TOFU connect → clock skew → HEVC negotiate → shared-D3D11 + D3D11VA init → WASAPI →
|
||||
session end; synthetic payload isn't decodable so decode output stays unvalidated), speed-test
|
||||
E2E. The WinUI window itself CANNOT be launched from SSH (session-0 → WinAppSDK 0x80070005,
|
||||
pre-existing; needs the console session, e.g. PsExec -i 1). **UX batch (2026-07-02 evening,
|
||||
UIA-smoke-tested on the hybrid laptop)**: host tiles get the WinUI pointer-over fill
|
||||
(`on_pointer_entered`/`exited` → root hover state → `ControlFillSecondary`), Settings is a stock
|
||||
**NavigationView** sidebar (Windows-Settings pattern: Display/Video/Input/Audio/About panes,
|
||||
built-in back arrow, section in root state; the section card is **keyed by section** — an
|
||||
in-place diff across sections re-sets a reused ComboBox's items, clearing WinUI's selection,
|
||||
but skips `selected_index` when the values compare equal → blank selection; the key forces a
|
||||
remount — and the content column rides its own section-switch slide-up tween), new
|
||||
**"Show the stats overlay (HUD)"** toggle
|
||||
(`Settings::show_hud`, applies mid-stream via the 400 ms HUD re-render), the Add-host modal
|
||||
slides up + fades in (root margin/opacity tween, same pattern as screen navigation), and a
|
||||
self-initiated disconnect (Ctrl+Alt+Shift+D → `Ended(None)`) returns to the host list silently
|
||||
instead of raising the error banner.
|
||||
Next: **HDR on-glass validation** (Windows host with `PUNKTFUNK_10BIT` → the HDR laptop
|
||||
display), then RAWINPUT relative-mouse pointer-lock.
|
||||
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
|
||||
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
|
||||
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
||||
Opus/AAudio audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
|
||||
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
|
||||
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
|
||||
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
|
||||
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
|
||||
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
|
||||
(`ci/play-upload.py`). Touch input is the same 3-way model as iOS (2026-07-02): the existing
|
||||
Trackpad/Direct mouse modes plus new **real multi-touch passthrough**
|
||||
(`streamTouchPassthrough` → `nativeSendTouch` → wire TouchDown/Move/Up), a `TouchMode`
|
||||
Settings dropdown replacing the old trackpad Boolean (migrated on load); not yet
|
||||
on-device validated. Next: real-device gamepad/HDR live-verify, presenter/latency polish.
|
||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||
at high res).
|
||||
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
|
||||
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
|
||||
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
|
||||
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
|
||||
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
|
||||
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
|
||||
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
|
||||
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
|
||||
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
|
||||
the host-lifetime input/audio/mic services (shared-desktop multi-view on kwin/mutter/wlroots).
|
||||
**Done:** delegated pairing approval (§8b-1) — an unpaired device shows up as a pending request in
|
||||
the web console, one click approves + pins it. Next (see roadmap): gamescope multi-user isolation
|
||||
(per-session input/audio = independent desktops); §8b-2 peer-push approval from a paired device's
|
||||
own app.
|
||||
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
|
||||
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
|
||||
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
|
||||
and unit/live-capture tested — both still need a live Moonlight confirmation (select
|
||||
AV1 in a stock client; a real 5.1/7.1 listen incl. FEC under loss).
|
||||
|
||||
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
|
||||
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
|
||||
backend validated live). All three compositor backends are live-validated.
|
||||
|
||||
## Build / test / run
|
||||
|
||||
```sh
|
||||
cargo build --workspace # green on Linux and macOS
|
||||
cargo test --workspace # unit + loopback + proptest + C ABI harness (~100 tests)
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all --check
|
||||
|
||||
cargo run -p loss-harness # FEC loss-resilience sweep (no network needed)
|
||||
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
|
||||
```
|
||||
|
||||
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
|
||||
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
|
||||
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
|
||||
|
||||
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
|
||||
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
|
||||
build+typecheck; `docker.yml` builds+pushes the web/docs/rust-ci images (host and native
|
||||
clients are deliberately NOT containerized); `apple.yml` builds the xcframework and runs
|
||||
`swift build`/`swift test` on the `macos-arm64` host-mode runner (home-mac-mini-1,
|
||||
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
|
||||
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
|
||||
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
|
||||
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner
|
||||
(`home-windows-runner-1`, vmid 210, `windows-amd64:host` label). The runner is reproducible and
|
||||
**owned by `unom/infra`**, not this repo, since it's shared across unom Windows projects going
|
||||
forward: `unom/infra`'s `windows-runner/` Packer template bakes a generic Windows 11 template (OS
|
||||
install + OpenSSH Server + VS Build Tools/NASM/CMake/LLVM + the act_runner/Node/rustup base, no
|
||||
registration) on Proxmox once; `proxmox/windows-runner/` (Terraform, `bpg/proxmox`) full-clones it
|
||||
(agent-based IP discovery, no pre-provisioned DHCP reservation needed) and registers the instance
|
||||
over SSH remote-exec — the same bake-once/clone-fast split `proxmox/unom-1` uses for the Linux CI
|
||||
host, just without a native Windows cloud-init (registration goes over `remote-exec`/SSH instead of
|
||||
`initialization{}`; WinRM was tried first but is deprecated in OpenTofu, so this moved to SSH via
|
||||
Windows' in-box OpenSSH Server). punktfunk layers its own extras on top of that generic runner:
|
||||
`scripts/ci/provision-windows-wdk.ps1` (WDK + cargo-wdk for the UMDF drivers) and
|
||||
`scripts/ci/provision-windows-punktfunk-extras.ps1` (FFmpeg x64/ARM64 trees, Inno Setup, the
|
||||
`aarch64-pc-windows-msvc` rustup target) — both idempotent, and both run automatically at the start
|
||||
of every Windows CI job via the shared `scripts/ci/ensure-windows-toolchain.ps1` step (a fast no-op
|
||||
once already provisioned), rather than a separate manually-dispatched provisioning workflow — that
|
||||
avoided a real footgun once there could be more than one `windows-amd64` runner: a manually
|
||||
dispatched provisioning workflow has no way to target a *specific* runner instance, so it could
|
||||
land on an already-provisioned box instead of the one that actually needed it.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
|
||||
crates/punktfunk-host/
|
||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
||||
vdisplay/linux/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
||||
vdisplay/windows/{pf_vdisplay,manager,identity}.rs all-Rust IddCx virtual display (pf-vdisplay)
|
||||
linux/zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||
inject/linux/{libei,wlr,gamepad,dualsense,dualshock4,steam_*}.rs Linux input (uinput xpad · UHID pads · virtual Deck)
|
||||
inject/windows/{sendinput,gamepad_windows,dualsense_windows,dualshock4_windows}.rs Windows input (UMDF shared-mem pads)
|
||||
encode/linux/{mod,vaapi}.rs · encode/windows/{nvenc,ffmpeg_win}.rs · encode/sw.rs per-GPU encoders (NVENC/CUDA · VAAPI · AMF/QSV) + GPU-less openh264
|
||||
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
|
||||
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
|
||||
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
|
||||
crates/punktfunk-tray/ per-user status tray (Win32 Shell_NotifyIcon · Linux ksni/SNI); icons via scripts/gen-tray-icons.py
|
||||
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
|
||||
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
|
||||
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
||||
clients/decky/ Steam Deck Decky plugin
|
||||
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
|
||||
packaging/windows/drivers/pf-umdf-util/ audited unsafe layer (safe shm + sealed-channel + WDF request primitives) — gamepad drivers' logic is 100% safe over it
|
||||
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
|
||||
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
|
||||
include/punktfunk_core.h generated C header
|
||||
```
|
||||
|
||||
## Design invariants — do not regress
|
||||
|
||||
- **One core, linked everywhere.** Protocol/FEC/crypto live only in `punktfunk-core`, behind a
|
||||
stable, versioned C ABI. `tokio`/`quinn` exist only behind the `quic` feature (control
|
||||
plane); **no async on the per-frame path** — native threads only.
|
||||
- **Native client resolution, no scaling.** A session gets a virtual output at exactly the
|
||||
client's WxH@Hz via the `VirtualDisplay` trait (`create(mode) → VirtualOutput { node_id,
|
||||
remote_fd, preferred_mode, keepalive }`, RAII teardown). There is no cross-compositor
|
||||
protocol for this — each compositor keeps its own backend.
|
||||
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
|
||||
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
|
||||
ceiling.
|
||||
- **Core security hardening stays intact**: reassembler bounds attacker-controlled fields
|
||||
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
|
||||
ABI `struct_size` checks. Regression tests exist — keep them green.
|
||||
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
|
||||
down promptly on negotiation timeout — one wedged link head-blocks the daemon's shared
|
||||
work queue system-wide.
|
||||
|
||||
## Running on this box
|
||||
|
||||
Headless QEMU VM (Ubuntu 26.04, kernel 7.0), passthrough RTX 5070 Ti (driver 595 **open**
|
||||
module — a kernel update silently drops it; reinstall `nvidia-driver-595-open`), no KMS
|
||||
scanout → KWin `--drm` impossible; everything renders offscreen via `renderD128`.
|
||||
|
||||
```sh
|
||||
# compositor session (shell 1, or the systemd unit in scripts/): full headless Plasma.
|
||||
# The script sets XDG_MENU_PREFIX=plasma- & co. — without it plasmashell runs but the
|
||||
# launcher menu is EMPTY (no apps, no System Settings).
|
||||
bash scripts/headless/run-headless-kde.sh 1920x1080
|
||||
|
||||
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
|
||||
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
|
||||
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
|
||||
|
||||
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
|
||||
# across sessions — bound it with --max-sessions):
|
||||
cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 10 --max-sessions 1
|
||||
cargo run -rp punktfunk-probe -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
|
||||
```
|
||||
|
||||
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
|
||||
(`ffmpeg-sys-next` auto-detects the system FFmpeg, so it builds against **FFmpeg 7.x/libavcodec 61
|
||||
or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7.1); the zero-copy
|
||||
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
|
||||
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1|0` (Linux default: ON for
|
||||
VAAPI/AMD/Intel with a one-shot CPU downgrade if the dmabuf offer never negotiates, OFF/opt-in for
|
||||
NVENC), `PUNKTFUNK_VAAPI_LOW_POWER=1|0` (pin the VAAPI entrypoint; auto = full-feature then VDEnc
|
||||
fallback for modern Intel), `PUNKTFUNK_NV12=0` (opt OUT of the default GPU RGB→NV12 convert on the
|
||||
NVIDIA tiled zero-copy path), `PUNKTFUNK_INTRA_REFRESH=1` (opt-in NVENC intra-refresh loss recovery),
|
||||
`PUNKTFUNK_PIN_CLOCKS=1` (opt-in NVML GPU clock floor, root-gated), `PUNKTFUNK_GAMESCOPE_APP=...`,
|
||||
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
|
||||
test — injects N% wire-packet loss on BOTH the GameStream and native video paths, no netem needed), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
|
||||
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy),
|
||||
`PUNKTFUNK_444=1` (full-chroma HEVC 4:4:4, see below).
|
||||
|
||||
**HEVC 4:4:4 (full chroma, Range Extensions)**: opt-in via `PUNKTFUNK_444`, negotiated like 10-bit —
|
||||
the host emits 4:4:4 only when the client advertised `VIDEO_CAP_444` (wire bit `0x04` + ABI
|
||||
`PUNKTFUNK_VIDEO_CAP_444`), the codec is HEVC, the session is single-process, **and** a GPU probe
|
||||
(`encode::can_encode_444`, run before the Welcome) confirms support — else it resolves to 4:2:0 and
|
||||
`Welcome::chroma_format` reflects the real value (honest downgrade; the client reads it via
|
||||
`punktfunk_connection_chroma_format`). **punktfunk/1-native only** — GameStream/Moonlight stays 4:2:0
|
||||
(stock clients can't decode 4:4:4). **NVENC is the implemented path**: Linux `hevc_nvenc` feeds a
|
||||
swscale'd `yuv444p` (RGB-in is always 4:2:0 — verified on the RTX 5070 Ti — so the session forces CPU
|
||||
RGB capture for 4:4:4); Windows NVENC keeps ARGB input + FREXT profile + `chromaFormatIDC=3` and the
|
||||
DDA capturer delivers RGB. VAAPI / AMF / QSV **decline** (probe returns false — no validated 4:4:4
|
||||
hardware in the lab; they'd produce 4:2:0). Software (openh264) is 4:2:0-only. Test with
|
||||
`PUNKTFUNK_CLIENT_444=1 punktfunk-probe --out x.h265` then `ffprobe x.h265` (expect `pix_fmt yuv444p`).
|
||||
*Linux NVENC mechanism validated on the RTX 5070 Ti (ffmpeg CLI); Windows NVENC + 10-bit-4:4:4 not yet
|
||||
on-glass validated.*
|
||||
|
||||
## Conventions
|
||||
|
||||
- Rust 2021, `rustfmt` + `clippy -D warnings` clean before commit.
|
||||
- Match the surrounding code's comment density and naming.
|
||||
- Commit messages end with the Co-Authored-By trailer (see `git log`).
|
||||
- `pkill` caution on this box: match exact comm names (`pkill -x gamescope-wl`,
|
||||
`pkill -x punktfunk-host`) — `pkill -f` self-matches the invoking shell.
|
||||
Generated
+1
@@ -3027,6 +3027,7 @@ dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winreg",
|
||||
"winresource",
|
||||
"x509-parser",
|
||||
"xkbcommon",
|
||||
]
|
||||
|
||||
@@ -15,6 +15,9 @@ your local network.
|
||||
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
|
||||
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
|
||||
|
||||
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
|
||||
[SECURITY.md](SECURITY.md). Please don't open a public issue.
|
||||
|
||||
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
|
||||
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
|
||||
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
|
||||
@@ -138,7 +141,6 @@ clients/
|
||||
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
|
||||
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
|
||||
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
|
||||
design/ design notes & deep-dive plans (index: design/README.md)
|
||||
include/punktfunk_core.h cbindgen-generated C header (checked in)
|
||||
tools/ latency-probe · loss-harness (measurement)
|
||||
```
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
# Security Policy
|
||||
|
||||
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
|
||||
machine, so we take security reports seriously and appreciate responsible disclosure.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please report security issues privately by email to security@punktfunk.com.**
|
||||
|
||||
Do **not** open a public issue, pull request, or chat/forum post for a suspected vulnerability — that
|
||||
exposes other users before a fix exists.
|
||||
|
||||
### What to include
|
||||
|
||||
The more of this you can give us, the faster we can act:
|
||||
|
||||
- The component and version (e.g. `punktfunk-host 0.6.0`, Windows or Linux, which client).
|
||||
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
|
||||
admin, a paired client, …).
|
||||
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||
- Any suggested fix or mitigation (optional).
|
||||
|
||||
## What to expect
|
||||
|
||||
We're a small team, so timelines are best-effort, but we commit to:
|
||||
|
||||
- **Acknowledge** your report within **3 business days**.
|
||||
- Give an **initial assessment** (severity + whether we can reproduce) within about **7 days**.
|
||||
- Keep you updated, and tell you when a fix ships.
|
||||
- **Credit** you in the advisory / release notes when the fix is public — unless you'd rather stay
|
||||
anonymous.
|
||||
|
||||
We practice **coordinated disclosure**: please give us reasonable time to release a fix before
|
||||
publishing details. We aim to resolve valid issues within **90 days** and will agree a disclosure
|
||||
date with you.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope — the code in this repository:
|
||||
|
||||
- The host (`punktfunk-host`), its Windows drivers, and the protocol/crypto core (`punktfunk-core`).
|
||||
- The native clients (Apple, Linux, Windows, Android), the web management console, and the management
|
||||
API.
|
||||
|
||||
Known limits — documented behavior, not vulnerabilities (see
|
||||
https://docs.punktfunk.unom.io/docs/security):
|
||||
|
||||
- **Admin/SYSTEM already on the host = out of scope.** An attacker who is already administrator or
|
||||
SYSTEM on the host owns the machine regardless of punktfunk.
|
||||
- **The virtual display is a real monitor** — any process already in the interactive desktop session
|
||||
can capture it via the normal OS screen-capture APIs, exactly as it could a physical monitor.
|
||||
- **GameStream/Moonlight compatibility** (`--gamestream`) uses legacy encryption and is documented as
|
||||
opt-in, trusted-LAN-only.
|
||||
- **Public-internet exposure is unsupported** — issues that only arise from exposing the host to the
|
||||
WAN are expected; keep the host on a trusted LAN or a VPN.
|
||||
|
||||
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
|
||||
|
||||
## Safe harbor
|
||||
|
||||
We consider good-faith security research that follows this policy to be authorized, and we won't
|
||||
pursue legal action against researchers who:
|
||||
|
||||
- make a good-faith effort to avoid privacy violations, data loss, and service disruption,
|
||||
- only test systems they own or have explicit permission to test,
|
||||
- give us reasonable time to remediate before public disclosure,
|
||||
- don't exfiltrate more data than needed to demonstrate the issue.
|
||||
|
||||
Thank you for helping keep punktfunk and its users safe.
|
||||
@@ -7,63 +7,15 @@ import SwiftUI
|
||||
extension SettingsView {
|
||||
// MARK: - Sections (shared)
|
||||
|
||||
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
||||
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
||||
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
||||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||||
@ViewBuilder var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS)
|
||||
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
|
||||
// a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host
|
||||
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
|
||||
// last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
iosResolutionWheel
|
||||
iosRefreshRows
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
@@ -78,23 +30,7 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
bitrateRows
|
||||
#endif
|
||||
} header: {
|
||||
Text("Stream mode")
|
||||
@@ -109,6 +45,67 @@ extension SettingsView {
|
||||
#if os(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
|
||||
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — the
|
||||
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
||||
/// chosen mode, so these are real pixel sizes. The last wheel row, "Custom…", reveals
|
||||
/// width/height/refresh fields for an arbitrary mode (see `iosRefreshRows`).
|
||||
@ViewBuilder private var iosResolutionWheel: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom W×H(+Hz) fields, a segmented refresh picker, or a static single-rate row.
|
||||
@ViewBuilder private var iosRefreshRows: some View {
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't
|
||||
/// collide with a resolution.
|
||||
private static let customResolutionTag = "custom"
|
||||
@@ -156,6 +153,29 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||
@ViewBuilder private var bitrateRows: some View {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
@@ -204,35 +224,42 @@ extension SettingsView {
|
||||
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
||||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||||
@ViewBuilder var pointerSection: some View {
|
||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
Section {
|
||||
Picker("Touch input", selection: $touchMode) {
|
||||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||||
}
|
||||
if isPad {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
}
|
||||
} header: {
|
||||
Text("Touch & pointer")
|
||||
} footer: {
|
||||
Text("Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
|
||||
+ "click, two-finger tap for a right click, two-finger drag to scroll, "
|
||||
+ "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
|
||||
+ "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
|
||||
+ "multi-touch reaches the host, for apps that understand touch. Applies from "
|
||||
+ "the next touch."
|
||||
+ (isPad
|
||||
? " Pointer capture locks a hardware mouse/trackpad for relative movement "
|
||||
+ "(mouse-look); off keeps the pointer free and sends absolute positions. "
|
||||
+ "The lock needs the stream full-screen and frontmost, and falls back "
|
||||
+ "automatically (Stage Manager, Slide Over)."
|
||||
: ""))
|
||||
Text(pointerFooterText)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
|
||||
/// `+` chain (with a ternary) inside the ViewBuilder — that single expression blew Swift's
|
||||
/// type-checker budget and was what actually broke the iOS archive.
|
||||
private var pointerFooterText: String {
|
||||
var text = "Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
|
||||
text += "click, two-finger tap for a right click, two-finger drag to scroll, "
|
||||
text += "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
|
||||
text += "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
|
||||
text += "multi-touch reaches the host, for apps that understand touch. Applies from "
|
||||
text += "the next touch."
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
text += " Pointer capture locks a hardware mouse/trackpad for relative movement "
|
||||
text += "(mouse-look); off keeps the pointer free and sends absolute positions. "
|
||||
text += "The lock needs the stream full-screen and frontmost, and falls back "
|
||||
text += "automatically (Stage Manager, Slide Over)."
|
||||
}
|
||||
return text
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var compositorSection: some View {
|
||||
|
||||
@@ -243,3 +243,8 @@ nvenc = ["dep:nvidia-video-codec-sdk"]
|
||||
# so the LGPL build suffices and keeps the bundled DLLs LGPL, not GPL) at build time and bundles the
|
||||
# FFmpeg DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`.
|
||||
amf-qsv = ["dep:ffmpeg-next"]
|
||||
|
||||
# Build-time icon/version-info embedding (build.rs; Windows dev/CI hosts only — Linux packaging
|
||||
# builds of this crate never execute the winresource block).
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -17,4 +17,21 @@ fn main() {
|
||||
.unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()));
|
||||
println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}");
|
||||
println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION");
|
||||
|
||||
// Windows identity resources: the branded icon + version info. Task Manager / Explorer show a
|
||||
// process by its version-info FileDescription — without one the host appears as a bare
|
||||
// "punktfunk-host.exe" with no icon. Same winresource pattern as clients/windows and
|
||||
// punktfunk-tray (cfg(windows) = build HOST, so Linux packaging builds skip it; CARGO_CFG_WINDOWS
|
||||
// = TARGET).
|
||||
#[cfg(windows)]
|
||||
if std::env::var_os("CARGO_CFG_WINDOWS").is_some() {
|
||||
let icon = "../../packaging/windows/branding/punktfunk.ico";
|
||||
println!("cargo:rerun-if-changed={icon}");
|
||||
winresource::WindowsResource::new()
|
||||
.set_icon_with_id(icon, "1")
|
||||
.set("FileDescription", "Punktfunk Host")
|
||||
.set("ProductName", "Punktfunk")
|
||||
.compile()
|
||||
.expect("embed windows icon/version resources");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,11 +215,24 @@ unsafe fn open_win_encoder(
|
||||
let mut opts = Dictionary::new();
|
||||
match vendor {
|
||||
WinVendor::Amf => {
|
||||
opts.set("usage", "ultralowlatency");
|
||||
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
|
||||
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies by
|
||||
// VCN generation/driver — measured on-box rather than assumed.
|
||||
let usage =
|
||||
std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
|
||||
opts.set("usage", &usage);
|
||||
opts.set("rc", "cbr");
|
||||
opts.set("quality", "balanced");
|
||||
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
|
||||
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
|
||||
// low-latency preset choice on the NVENC path).
|
||||
opts.set("quality", "speed");
|
||||
opts.set("preanalysis", "false");
|
||||
opts.set("enforce_hrd", "true");
|
||||
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
|
||||
opts.set("latency", "true");
|
||||
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
|
||||
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
|
||||
opts.set("bf", "0");
|
||||
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
|
||||
opts.set("header_insertion_mode", "idr");
|
||||
}
|
||||
@@ -292,14 +305,22 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// One `receive_packet` attempt, with the not-ready states kept distinct so the blocking poll
|
||||
/// below can tell "still encoding" (retry) from "stream over" (stop).
|
||||
enum PollOutcome {
|
||||
Packet(EncodedFrame),
|
||||
Again,
|
||||
Eof,
|
||||
}
|
||||
|
||||
/// Drain the encoder for one packet (shared poll logic, identical to the VAAPI/NVENC paths).
|
||||
fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<Option<EncodedFrame>> {
|
||||
fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<PollOutcome> {
|
||||
let mut pkt = Packet::empty();
|
||||
match enc.receive_packet(&mut pkt) {
|
||||
Ok(()) => {
|
||||
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
|
||||
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
|
||||
Ok(Some(EncodedFrame {
|
||||
Ok(PollOutcome::Packet(EncodedFrame {
|
||||
data,
|
||||
pts_ns: pts * 1_000_000_000 / fps as u64,
|
||||
keyframe: pkt.is_key(),
|
||||
@@ -309,9 +330,9 @@ fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<Option<En
|
||||
if errno == ffmpeg::util::error::EAGAIN
|
||||
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
|
||||
{
|
||||
Ok(None)
|
||||
Ok(PollOutcome::Again)
|
||||
}
|
||||
Err(ffmpeg::Error::Eof) => Ok(None),
|
||||
Err(ffmpeg::Error::Eof) => Ok(PollOutcome::Eof),
|
||||
Err(e) => Err(e).context("receive_packet"),
|
||||
}
|
||||
}
|
||||
@@ -1100,6 +1121,9 @@ pub struct FfmpegWinEncoder {
|
||||
bound_device: isize,
|
||||
frame_idx: i64,
|
||||
force_kf: bool,
|
||||
/// Frames sent to libavcodec whose AUs haven't been received yet. `poll` blocks (bounded)
|
||||
/// while this is non-zero — see the poll-contract note on [`Encoder::poll`] below.
|
||||
in_flight: usize,
|
||||
}
|
||||
|
||||
// Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI).
|
||||
@@ -1161,6 +1185,7 @@ impl FfmpegWinEncoder {
|
||||
bound_device: 0,
|
||||
frame_idx: 0,
|
||||
force_kf: false,
|
||||
in_flight: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1231,7 +1256,7 @@ impl Encoder for FfmpegWinEncoder {
|
||||
self.frame_idx += 1;
|
||||
let idr = self.force_kf;
|
||||
self.force_kf = false;
|
||||
match &captured.payload {
|
||||
let submitted = match &captured.payload {
|
||||
FramePayload::D3d11(f) => {
|
||||
self.ensure_inner_d3d11(&f.device)?;
|
||||
// If zero-copy is active but the capturer fell back to a format the NV12/P010 pool
|
||||
@@ -1271,18 +1296,60 @@ impl Encoder for FfmpegWinEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if submitted.is_ok() {
|
||||
self.in_flight += 1;
|
||||
}
|
||||
submitted
|
||||
}
|
||||
|
||||
fn request_keyframe(&mut self) {
|
||||
self.force_kf = true;
|
||||
}
|
||||
|
||||
/// Poll for the next finished AU (single non-blocking `receive_packet`).
|
||||
///
|
||||
/// libavcodec's `hevc_amf`/`av1_amf` wrapper holds ~2 frames before releasing the oldest
|
||||
/// (it needs frame N+2 submitted to flush N), so the encode→retrieve latency floors at
|
||||
/// **~2 frame periods** — measured dead-stable at 36 ms p50 for 720p60 on the Ryzen 7000
|
||||
/// iGPU across depth 1/2, every `usage` preset, and any spin (a spin between submits provably
|
||||
/// never produces the owed AU — verified with a 150 ms cap pegging at exactly 150 ms). So the
|
||||
/// buffer is inherent to the libavcodec path, NOT host scheduling: the real fix is a direct
|
||||
/// AMF SDK encoder (the AMF analogue of `encode/windows/nvenc.rs`, whose delay=0 gives NVENC
|
||||
/// its ~1–2 ms) — tracked as the next AMD latency lever. `PUNKTFUNK_FFWIN_POLL_MS` keeps a
|
||||
/// bounded spin available for a future VCN/driver where the AU can land mid-spin (0 = off,
|
||||
/// the default and correct choice on measured hardware).
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
match &mut self.inner {
|
||||
Some(Inner::System(s)) => poll_encoder(&mut s.enc, self.fps),
|
||||
Some(Inner::ZeroCopy(z)) => poll_encoder(&mut z.enc, self.fps),
|
||||
None => Ok(None),
|
||||
let fps = self.fps;
|
||||
let enc = match &mut self.inner {
|
||||
Some(Inner::System(s)) => &mut s.enc,
|
||||
Some(Inner::ZeroCopy(z)) => &mut z.enc,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let cap_us = std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(|ms| ms * 1000)
|
||||
.unwrap_or(0); // default: no spin — the libavcodec AMF buffer can't be spun out
|
||||
let deadline = (cap_us > 0 && self.in_flight > 0)
|
||||
.then(|| std::time::Instant::now() + std::time::Duration::from_micros(cap_us));
|
||||
loop {
|
||||
match poll_encoder(enc, fps)? {
|
||||
PollOutcome::Packet(au) => {
|
||||
self.in_flight = self.in_flight.saturating_sub(1);
|
||||
return Ok(Some(au));
|
||||
}
|
||||
PollOutcome::Eof => {
|
||||
self.in_flight = 0; // flushed: nothing further is owed
|
||||
return Ok(None);
|
||||
}
|
||||
PollOutcome::Again => match deadline {
|
||||
Some(d) if std::time::Instant::now() < d => {
|
||||
std::thread::sleep(std::time::Duration::from_micros(250));
|
||||
}
|
||||
_ => return Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -534,7 +534,9 @@ impl DriverAttach {
|
||||
driver_log = self.driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad"
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFDS_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +392,21 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
register_web_task(&cmd)?;
|
||||
// 4. firewall: inbound TCP 47992. The console serves HTTPS (HTTP/1.1 over TLS) with the host's
|
||||
// identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.)
|
||||
// Scoped to the same profiles as the streaming ports — Domain + Private by default, Public
|
||||
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
|
||||
// instead of stacking a second (possibly all-profiles) rule behind the new one.
|
||||
let fw_profile =
|
||||
crate::service::firewall_profile_arg(crate::service::allow_public_network(args));
|
||||
run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
"advfirewall",
|
||||
"firewall",
|
||||
"delete",
|
||||
"rule",
|
||||
"name=punktfunk web console (TCP 47992)",
|
||||
],
|
||||
);
|
||||
if !run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
@@ -404,6 +419,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
"action=allow",
|
||||
"protocol=TCP",
|
||||
"localport=47992",
|
||||
fw_profile,
|
||||
],
|
||||
) {
|
||||
eprintln!("warning: could not add the firewall rule for TCP 47992");
|
||||
|
||||
@@ -57,7 +57,7 @@ use windows::Win32::System::Threading::{
|
||||
|
||||
/// SCM service name (the key under HKLM\SYSTEM\CurrentControlSet\Services). Stable identity.
|
||||
const SERVICE_NAME: &str = "PunktfunkHost";
|
||||
const SERVICE_DISPLAY: &str = "punktfunk streaming host";
|
||||
const SERVICE_DISPLAY: &str = "Punktfunk Host";
|
||||
const SERVICE_DESCRIPTION: &str =
|
||||
"Low-latency desktop/game streaming host. Launches the punktfunk host into the active session.";
|
||||
|
||||
@@ -130,6 +130,23 @@ fn host_log_path() -> PathBuf {
|
||||
dir.join("host.log")
|
||||
}
|
||||
|
||||
/// One-generation size cap for the append-forever logs: at each (re)open, a file over this size is
|
||||
/// renamed to `<name>.old` (replacing the previous generation) — so a crash-restart loop or a
|
||||
/// `RUST_LOG=debug` left in host.env can't grow them without bound.
|
||||
const LOG_ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Rotate `path` to `path.old` when it has outgrown [`LOG_ROTATE_BYTES`]. Only called right before
|
||||
/// an open (service start for service.log, each host (re)launch for host.log) — never while a live
|
||||
/// handle appends: renaming under an appender would silently redirect its writes into the `.old`
|
||||
/// file. Best-effort; a failed rename just means one more un-rotated run.
|
||||
fn rotate_if_large(path: &std::path::Path) {
|
||||
if std::fs::metadata(path).is_ok_and(|m| m.len() >= LOG_ROTATE_BYTES) {
|
||||
let mut old = path.as_os_str().to_owned();
|
||||
old.push(".old");
|
||||
let _ = std::fs::rename(path, std::path::Path::new(&old));
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise tracing to the service log file (the SCM gives the service no console/stderr). Falls
|
||||
/// back to stderr if the file can't be opened. Called from `main()` only for `service run`.
|
||||
/// Also tees into the in-memory log ring (`log_capture`), like the stderr path in `main()` — the
|
||||
@@ -140,10 +157,12 @@ pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
|
||||
use tracing_subscriber::Layer;
|
||||
let ring =
|
||||
crate::log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG);
|
||||
let log_path = service_log_path();
|
||||
rotate_if_large(&log_path);
|
||||
match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(service_log_path())
|
||||
.open(log_path)
|
||||
{
|
||||
Ok(file) => {
|
||||
tracing_subscriber::registry()
|
||||
@@ -302,6 +321,10 @@ fn run_service() -> Result<()> {
|
||||
.context("set RUNNING")?;
|
||||
tracing::info!("punktfunk service started — supervising host in the active console session");
|
||||
|
||||
// Best-effort: warn if this network is Public (streaming ports are firewalled off there unless
|
||||
// the operator opted in). Own thread — a slow `Get-NetConnectionProfile` never delays the host.
|
||||
std::thread::spawn(warn_if_public_network);
|
||||
|
||||
load_host_env();
|
||||
let result = supervise(stop, session);
|
||||
|
||||
@@ -545,8 +568,12 @@ unsafe fn spawn_host(
|
||||
let _ = DestroyEnvironmentBlock(env_block);
|
||||
}
|
||||
|
||||
// 3) Redirect the host's stdout+stderr to host.log (inheritable handle).
|
||||
let log = open_log_handle(&host_log_path())?;
|
||||
// 3) Redirect the host's stdout+stderr to host.log (inheritable handle). The previous child has
|
||||
// exited by the time the supervise loop relaunches, so its handle can't be live here — safe
|
||||
// to rotate. (A leaked orphan's handle lacks FILE_SHARE_DELETE, so the rename just fails.)
|
||||
let host_log = host_log_path();
|
||||
rotate_if_large(&host_log);
|
||||
let log = open_log_handle(&host_log)?;
|
||||
|
||||
let mut si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
@@ -683,7 +710,14 @@ fn install(args: &[String]) -> Result<()> {
|
||||
if let Some(on) = gamestream {
|
||||
apply_gamestream_choice(on);
|
||||
}
|
||||
add_firewall_rules();
|
||||
// Firewall scope: Domain + Private by default; `--allow-public-network` opts into Public too.
|
||||
// Persist the choice (so the startup warning respects an opt-in) and re-scope idempotently —
|
||||
// remove any prior rules first so an upgrade tightens the scope instead of leaving a stale
|
||||
// all-profiles rule behind the new one.
|
||||
let allow_public = allow_public_network(args);
|
||||
set_fw_public_marker(allow_public);
|
||||
remove_firewall_rules();
|
||||
add_firewall_rules(allow_public);
|
||||
|
||||
println!(
|
||||
"\nInstalled. Config: {}\nLogs: {}\n\nStart now with: punktfunk-host service start",
|
||||
@@ -839,8 +873,28 @@ fn apply_gamestream_choice(enable: bool) {
|
||||
|
||||
// ── firewall + sc helpers ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The `netsh` `profile=` scope for punktfunk's inbound rules. Default = **Domain + Private** — the
|
||||
/// trusted-network profiles punktfunk is meant to run on; `allow_public` widens it to **all profiles
|
||||
/// including Public** (untrusted networks like café/hotel Wi-Fi — opt-in only). Shared with the
|
||||
/// web-console rule in `install.rs` so both surfaces scope the same way.
|
||||
pub(crate) fn firewall_profile_arg(allow_public: bool) -> &'static str {
|
||||
if allow_public {
|
||||
"profile=any"
|
||||
} else {
|
||||
"profile=domain,private"
|
||||
}
|
||||
}
|
||||
|
||||
/// The `--allow-public-network` install opt-in (the installer's "Allow connections on Public
|
||||
/// networks" task forwards it). Absent = the secure default (Domain + Private only).
|
||||
pub(crate) fn allow_public_network(args: &[String]) -> bool {
|
||||
args.iter().any(|a| a == "--allow-public-network")
|
||||
}
|
||||
|
||||
/// Inbound firewall rules for the streaming ports (best-effort; logs but never fails the install).
|
||||
fn add_firewall_rules() {
|
||||
/// Scoped by [`firewall_profile_arg`]: Domain + Private by default, all profiles when `allow_public`.
|
||||
fn add_firewall_rules(allow_public: bool) {
|
||||
let profile = firewall_profile_arg(allow_public);
|
||||
// (name suffix, protocol, ports)
|
||||
let rules = [
|
||||
("TCP", "TCP", "47984,47989,48010,47990"),
|
||||
@@ -860,14 +914,22 @@ fn add_firewall_rules() {
|
||||
"action=allow",
|
||||
&format!("protocol={proto}"),
|
||||
&format!("localport={ports}"),
|
||||
profile,
|
||||
],
|
||||
);
|
||||
if ok {
|
||||
println!("Firewall rule added: {name} ({ports})");
|
||||
println!("Firewall rule added: {name} ({ports}) [{profile}]");
|
||||
} else {
|
||||
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
|
||||
}
|
||||
}
|
||||
if !allow_public {
|
||||
println!(
|
||||
"Note: streaming ports are open on Private/Domain networks only. On a network Windows \
|
||||
classifies as Public, clients won't connect — set that network to Private, or reinstall \
|
||||
with the 'Allow connections on Public networks' option."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_firewall_rules() {
|
||||
@@ -886,6 +948,62 @@ fn remove_firewall_rules() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker file recording that the operator opted into opening the firewall on **Public** networks
|
||||
/// (`--allow-public-network`). Its presence suppresses the startup Public-network warning (they made
|
||||
/// an informed choice); absence = the secure default.
|
||||
fn fw_public_marker() -> std::path::PathBuf {
|
||||
crate::gamestream::config_dir().join("fw-allow-public")
|
||||
}
|
||||
|
||||
/// Record (or clear) the Public-firewall opt-in marker to match this install's choice.
|
||||
fn set_fw_public_marker(allow_public: bool) {
|
||||
let path = fw_public_marker();
|
||||
if allow_public {
|
||||
let _ = std::fs::write(&path, b"1\n");
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: is any active network connection classified **Public** by Windows? Uses
|
||||
/// `Get-NetConnectionProfile` (per-interface category: Public / Private / DomainAuthenticated).
|
||||
/// `None` when it can't be determined — the caller then skips the warning.
|
||||
fn active_network_is_public() -> Option<bool> {
|
||||
let out = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"(Get-NetConnectionProfile).NetworkCategory",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
Some(s.lines().any(|l| l.trim().eq_ignore_ascii_case("Public")))
|
||||
}
|
||||
|
||||
/// One-shot startup diagnostic: if the operator did NOT opt into Public networks and this machine's
|
||||
/// current network is classified Public, the streaming ports are firewalled off there — turn that
|
||||
/// silent "clients can't connect" into an actionable WARN. Best-effort; meant to run on its own
|
||||
/// thread so it never delays the host launch.
|
||||
fn warn_if_public_network() {
|
||||
if fw_public_marker().exists() {
|
||||
return; // operator opted into Public — their informed choice, no warning
|
||||
}
|
||||
if active_network_is_public() == Some(true) {
|
||||
tracing::warn!(
|
||||
"this machine's current network is classified Public (an untrusted-network profile), so \
|
||||
punktfunk's streaming ports are firewalled off here and clients on this network can't \
|
||||
reach the host. Fix: set the network to Private (Windows Settings > Network > \
|
||||
properties) — or, only for a network you trust, reinstall with the 'Allow connections \
|
||||
on Public networks' option."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run an `sc.exe` command, passing its output through (used by start/stop/status).
|
||||
fn sc(args: &[&str]) -> Result<()> {
|
||||
let status = std::process::Command::new("sc")
|
||||
|
||||
@@ -22,6 +22,9 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed={path}");
|
||||
res.set_icon_with_id(path, id);
|
||||
}
|
||||
// Task Manager / Explorer identity (matches the host's "Punktfunk Host").
|
||||
res.set("FileDescription", "Punktfunk Tray");
|
||||
res.set("ProductName", "Punktfunk");
|
||||
res.compile().expect("embed windows icon resources");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ existing `service()` poll. State machine, each transition logs exactly once:
|
||||
|---|---|---|---|---|
|
||||
| 1 | Driver package not installed | fresh box, installer's `driver install --gamepad` skipped/failed, package pruned | attach timeout → `pnputil /enum-drivers` misses `pf_xusb.inf`/`pf_dualsense.inf` | WARN `driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad` |
|
||||
| 2 | Package present but binding failed | certificate not in Root/TrustedPublisher, Memory Integrity (HVCI) rejects it, stale DriverVer kept the old binary | attach timeout → devnode problem code (28 = drivers not installed, 52 = signature rejected, 31/39 = load failure) | WARN with the CM problem code + hint |
|
||||
| 3 | Driver bound but crashed / never started | WUDFHost crash, `WdfDeviceCreate`/queue failure inside the driver | attach timeout → devnode status shows `driver_loaded`/`started` flags; the driver's own log (`C:\Users\Public\pf*-driver.log`) has the failing WDF call | WARN referencing both |
|
||||
| 3 | Driver bound but crashed / never started | WUDFHost crash, `WdfDeviceCreate`/queue failure inside the driver | attach timeout → devnode status shows `driver_loaded`/`started` flags; the driver's own log (`C:\Users\Public\pf*-driver.log`) has the failing WDF call — opt-in like pf-vdisplay's (debug builds, or `PFXUSB_DEBUG_LOG`/`PFDS_DEBUG_LOG` set system-wide + device restart) | WARN referencing both |
|
||||
| 4 | `SwDeviceCreate` fails outright | not Administrator/SYSTEM, PnP wedged, `_` in enumerator (E_INVALIDARG) | existing error path (unchanged) | WARN `SwDeviceCreate failed; … devnode unavailable`, pad continues on the out-of-band fallback |
|
||||
| 5 | `SwDeviceCreate` callback never fires | PnP service hung | **was silently mis-read as success** (zero-init `HRESULT(0)` + ignored `WaitForSingleObject` return). Fixed: `result` inits to `E_FAIL`, the wait result is checked | ERROR `enumeration callback never fired (10s) — PnP may be wedged` |
|
||||
| 6 | Driver attached, then WUDFHost died mid-session | crash, killed | `driver_heartbeat` freezes (DS/DS4: timer-driven, so a freeze is conclusive; XUSB: only advances while a game polls, so absence is *not* an error) | field exists for a future stall check; not auto-warned yet (XUSB semantics make a generic rule false-positive-prone) |
|
||||
|
||||
@@ -129,7 +129,7 @@ notes for context.
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_PERF` | `1` | Log per-stage timing (capture, encode, send) — handy when tuning latency. |
|
||||
| `RUST_LOG` | `info` · `debug` · `trace` | Log verbosity. On Windows, logs land in `%ProgramData%\punktfunk\logs\`. |
|
||||
| `RUST_LOG` | `info` · `debug` · `trace` | Log verbosity. On Windows, logs land in `%ProgramData%\punktfunk\logs\` (size-capped: a file over 10 MB is rotated to `.old` at the next service/host start, one generation kept). |
|
||||
| `PUNKTFUNK_FFMPEG_DEBUG` | set | Verbose libavcodec/FFmpeg logging from the encoder. |
|
||||
| `PUNKTFUNK_VIDEO_DROP` | `N` (percent) | Deliberately drop N% of video packets to exercise FEC recovery. **Testing only.** |
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ punktfunk-host serve
|
||||
Add `--gamestream` (alias `--moonlight`) to **also** run the GameStream/Moonlight-compatible planes
|
||||
(nvhttp pairing, RTSP, ENet control, `_nvstream` mDNS) — required for stock [Moonlight](/docs/moonlight)
|
||||
clients. This is **opt-in** because GameStream carries inherent on-path weaknesses (pairing over plain
|
||||
HTTP; its legacy control encryption can reuse GCM nonces — security-review #5/#9), so enable it **only
|
||||
on a trusted LAN**. The native plane is immune to those issues.
|
||||
HTTP; its legacy control encryption can reuse GCM nonces), so enable it **only on a trusted LAN**. The
|
||||
native plane is immune to those issues.
|
||||
|
||||
```sh
|
||||
punktfunk-host serve --gamestream
|
||||
|
||||
@@ -8,8 +8,8 @@ always-available host, run it as a service. There are two cases.
|
||||
|
||||
> The bundled unit `scripts/punktfunk-host.service` runs `serve --gamestream`, so it serves both the
|
||||
> native `punktfunk/1` plane and stock [Moonlight](/docs/moonlight) clients. For a **secure native-only
|
||||
> host** (no GameStream — its pairing runs over plain HTTP and its legacy encryption is weaker;
|
||||
> security-review #5/#9), drop `--gamestream` from the unit's `ExecStart` and use bare `serve`.
|
||||
> host** (no GameStream — its pairing runs over plain HTTP and its legacy encryption is weaker), drop
|
||||
> `--gamestream` from the unit's `ExecStart` and use bare `serve`.
|
||||
|
||||
## A. A desktop you log into
|
||||
|
||||
@@ -101,9 +101,15 @@ registers + starts the service for you (`/VERYSILENT` for unattended). Upgrades
|
||||
handled through Add/Remove Programs.
|
||||
|
||||
Prefer the CLI? Run `punktfunk-host service install` from an elevated prompt — see
|
||||
[Windows service](https://git.unom.io/unom/punktfunk/src/branch/main/docs/windows-service.md). For
|
||||
hardware encode you need a GPU — NVIDIA (NVENC), AMD (AMF), or Intel (QSV); the host falls back to
|
||||
software H.264 without one.
|
||||
[Windows Host](/docs/windows-host). For hardware encode you need a GPU — NVIDIA (NVENC), AMD (AMF), or
|
||||
Intel (QSV); the host falls back to software H.264 without one.
|
||||
|
||||
> **Firewall scope.** The installer opens the streaming + console ports on **Private and Domain**
|
||||
> networks only — not **Public**. If your LAN is (mis)classified Public, clients won't connect until
|
||||
> you set it to Private (Windows Settings → Network), and the host logs a warning when it's on a Public
|
||||
> network. For a trusted network Windows insists is Public, tick **"Allow connections on Public
|
||||
> networks"** at install (or pass `--allow-public-network` to `service install`). See
|
||||
> [Security & Safe Use](/docs/security) for the reasoning.
|
||||
|
||||
## Verifying
|
||||
|
||||
|
||||
@@ -54,12 +54,21 @@ If you want to stream from outside your home, tunnel in instead of opening up:
|
||||
- **Don't** map a router port to the host. A port-forward turns "trusted LAN service" into
|
||||
"internet-facing service" with none of the protections that implies.
|
||||
|
||||
A note for **portable machines**: the installer opens the streaming ports on the firewall for *all*
|
||||
network profiles, including Public. That's convenient at home but means that if you take a laptop host
|
||||
onto an untrusted network — a café, a hotel, a conference — other devices on that network can reach the
|
||||
ports and attempt to pair. Pairing still protects you (an attacker who doesn't know the PIN can't get
|
||||
in), but the safest habit is to stop the host service, or firewall it off, when you're on a network you
|
||||
don't control.
|
||||
A note on **Windows network profiles**: the installer opens the streaming and console ports only on
|
||||
**Private and Domain** networks — the profiles Windows uses for networks you've marked as trusted. On a
|
||||
network Windows classifies as **Public** (cafés, hotels, conferences — the default for unknown
|
||||
networks), the ports stay **closed**, so a laptop host won't accept connections there. That's the safe
|
||||
default, and it's the behavior you want on the move. Two things follow from it:
|
||||
|
||||
- **If your home network is *misclassified* as Public, clients won't connect.** Set it to Private
|
||||
(Windows Settings → Network & internet → your network → **Private network**). The host also logs a
|
||||
warning at startup when it detects it's on a Public network, so this doesn't fail silently.
|
||||
- **If you have a trusted network that Windows insists on marking Public** (some headless or
|
||||
no-gateway LAN setups), you can opt in during install — the **"Allow connections on Public
|
||||
networks"** checkbox (off by default). Only do this for a network you actually trust.
|
||||
|
||||
Either way, pairing is what ultimately gates access — but keeping the host off untrusted networks is
|
||||
the first line, and on the move the safest habit is still to stop the service when you don't need it.
|
||||
|
||||
## What actually protects you
|
||||
|
||||
@@ -109,9 +118,7 @@ We mitigate this deliberately:
|
||||
full-system. (This is why punktfunk dropped ViGEmBus.)
|
||||
- **Sealed internal channels.** The desktop-frame ring and the gamepad input/output channels are
|
||||
passed between the host and its drivers as duplicated handles to unnamed objects, so another local
|
||||
service can't open them by name to read your screen or forge controller input. (Details:
|
||||
[`idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md)
|
||||
and [`gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md).)
|
||||
service can't open them by name to read your screen or forge controller input.
|
||||
- **Secrets are locked down.** The management token, the host identity key, and the console password
|
||||
are stored with Administrators/SYSTEM-only permissions.
|
||||
|
||||
@@ -142,12 +149,20 @@ applies: keep it on a trusted LAN or a VPN, require pairing, and don't expose it
|
||||
- **Keep the host updated** — security fixes ship in new builds.
|
||||
- **On portable hosts**, stop the service when you're on an untrusted network.
|
||||
|
||||
## For the technically curious
|
||||
## Reporting a vulnerability
|
||||
|
||||
The deeper security design lives in the repository, and it's candid about residual limits:
|
||||
Found a security issue? **Email [security@punktfunk.com](mailto:security@punktfunk.com).** Please
|
||||
don't open a public issue, pull request, or chat post for a suspected vulnerability — that exposes
|
||||
other users before a fix is available.
|
||||
|
||||
- [`design/idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md) — the sealed frame channel (why the Windows capture path is isolated), and its honest floor.
|
||||
- [`design/gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md) — the sealed gamepad channel.
|
||||
- [`design/security-review-2026-06-28.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review-2026-06-28.md) and [`design/security-review.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review.md) — the standing security reviews.
|
||||
Helpful things to include:
|
||||
|
||||
Found a security issue? Please report it privately rather than opening a public issue.
|
||||
- The component and version — e.g. `punktfunk-host 0.6.0`, Windows or Linux, and which client.
|
||||
- The impact, and the attacker's position (same LAN, a paired client, a local service account,
|
||||
admin, …).
|
||||
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
|
||||
|
||||
We acknowledge reports within **3 business days** and practice coordinated disclosure — we'll keep
|
||||
you posted, agree a disclosure date, and credit you when the fix ships (unless you'd rather stay
|
||||
anonymous). The full policy is in
|
||||
[`SECURITY.md`](https://git.unom.io/unom/punktfunk/src/branch/main/SECURITY.md).
|
||||
|
||||
@@ -4,8 +4,7 @@ description: "Where the work stands across the core, the host, and the native cl
|
||||
---
|
||||
|
||||
A high-level view of where punktfunk stands. The ordered plan of work is on the
|
||||
[Roadmap](/docs/roadmap), and milestone-level detail lives in
|
||||
[`CLAUDE.md`](https://git.unom.io/unom/punktfunk/src/branch/main/CLAUDE.md).
|
||||
[Roadmap](/docs/roadmap).
|
||||
|
||||
## Milestones at a glance
|
||||
|
||||
|
||||
@@ -269,18 +269,45 @@ fn channel_cfg() -> ChannelConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
|
||||
/// or the `PFDS_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit
|
||||
/// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-report
|
||||
/// OUTPUT hex dumps stop being a sustained disk-write path during gameplay. DebugView can't see the
|
||||
/// UMDF host across session 0, so the file stays the bring-up diagnostic when enabled.
|
||||
fn file_log_enabled() -> bool {
|
||||
use std::sync::OnceLock;
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFDS_DEBUG_LOG").is_some())
|
||||
}
|
||||
|
||||
/// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex`
|
||||
/// (pf-vdisplay's pattern) — no per-line open/close.
|
||||
fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
|
||||
use std::sync::OnceLock;
|
||||
static APPENDER: OnceLock<Option<std::sync::Mutex<std::fs::File>>> = OnceLock::new();
|
||||
APPENDER
|
||||
.get_or_init(|| {
|
||||
if !file_log_enabled() {
|
||||
return None;
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfds-driver.log")
|
||||
.ok()
|
||||
.map(std::sync::Mutex::new)
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
fn log(s: &str) {
|
||||
if let Ok(c) = std::ffi::CString::new(s) {
|
||||
// SAFETY: c is a valid null-terminated string for the duration of the call.
|
||||
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
|
||||
}
|
||||
// Also append to a world-writable file — DebugView can't capture the UMDF host's output
|
||||
// across session 0, so this is how we read driver-start diagnostics.
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfds-driver.log")
|
||||
if let Some(m) = file_appender()
|
||||
&& let Ok(mut f) = m.lock()
|
||||
{
|
||||
let _ = writeln!(f, "{s}");
|
||||
}
|
||||
|
||||
@@ -104,16 +104,45 @@ fn channel_cfg() -> ChannelConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
|
||||
/// or the `PFXUSB_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit
|
||||
/// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-rumble
|
||||
/// SET_STATE hex dumps stop being a sustained disk-write path during gameplay. DebugView can't see
|
||||
/// the UMDF host across session 0, so the file stays the bring-up diagnostic when enabled.
|
||||
fn file_log_enabled() -> bool {
|
||||
use std::sync::OnceLock;
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFXUSB_DEBUG_LOG").is_some())
|
||||
}
|
||||
|
||||
/// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex`
|
||||
/// (pf-vdisplay's pattern) — no per-line open/close.
|
||||
fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
|
||||
use std::sync::OnceLock;
|
||||
static APPENDER: OnceLock<Option<std::sync::Mutex<std::fs::File>>> = OnceLock::new();
|
||||
APPENDER
|
||||
.get_or_init(|| {
|
||||
if !file_log_enabled() {
|
||||
return None;
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfxusb-driver.log")
|
||||
.ok()
|
||||
.map(std::sync::Mutex::new)
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
fn log(s: &str) {
|
||||
if let Ok(c) = std::ffi::CString::new(s) {
|
||||
// SAFETY: `c` is a valid NUL-terminated string for the duration of the call.
|
||||
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
|
||||
}
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfxusb-driver.log")
|
||||
if let Some(m) = file_appender()
|
||||
&& let Ok(mut f) = m.lock()
|
||||
{
|
||||
let _ = writeln!(f, "{s}");
|
||||
}
|
||||
|
||||
@@ -112,7 +112,9 @@ SetupIconFile={#BrandingDir}\punktfunk.ico
|
||||
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
|
||||
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
|
||||
UninstallDisplayName=punktfunk host {#MyAppVersion}
|
||||
; The branded multi-size .ico (installed below) - the host exe embeds no icon resource.
|
||||
; The branded multi-size .ico (installed below). The host exe now embeds the same icon + a
|
||||
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
|
||||
; copy stays as the uninstall-entry icon.
|
||||
UninstallDisplayIcon={app}\punktfunk.ico
|
||||
|
||||
[Languages]
|
||||
@@ -144,6 +146,11 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
|
||||
; in host.env; a hand-customized value is left alone). Checked = the Moonlight-compatible unified
|
||||
; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only).
|
||||
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
|
||||
; Firewall scope, forwarded as `--allow-public-network` to `service install` / `web setup`. Unchecked
|
||||
; (default) = accept connections on Private + Domain networks only (the trusted-network profiles
|
||||
; punktfunk is meant for). Check ONLY for a network you trust that Windows classifies as Public (e.g.
|
||||
; some headless / no-gateway LAN setups) - it opens the streaming + console ports on Public too.
|
||||
Name: "allowpublicfw"; Description: "Allow connections on Public networks (only for a trusted network Windows marks as Public)"; Flags: unchecked
|
||||
Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)"
|
||||
; The per-user status tray (punktfunk-tray.exe): shows running/stopped/failed at a glance and
|
||||
; offers open-console / start / stop / restart without a terminal. HKLM Run = every user who signs
|
||||
@@ -237,7 +244,7 @@ Filename: "powershell.exe"; \
|
||||
; Register (or re-point, on upgrade - idempotent) the SYSTEM service from its FINAL {app} location:
|
||||
; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}.
|
||||
; --gamestream=on|off carries the wizard's GameStream task choice into host.env's PUNKTFUNK_HOST_CMD.
|
||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install {code:GamestreamParam}"; WorkingDir: "{app}"; \
|
||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install {code:GamestreamParam}{code:PublicFwParam}"; WorkingDir: "{app}"; \
|
||||
StatusMsg: "Registering the punktfunk host service..."; Flags: runhidden waituntilterminated
|
||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "{app}"; \
|
||||
StatusMsg: "Starting the punktfunk host service..."; Flags: runhidden waituntilterminated; Tasks: startservice
|
||||
@@ -245,7 +252,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "
|
||||
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd
|
||||
; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure),
|
||||
; open TCP 47992, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
|
||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}"; WorkingDir: "{app}"; \
|
||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}{code:PublicFwParam}"; WorkingDir: "{app}"; \
|
||||
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
|
||||
#endif
|
||||
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
|
||||
@@ -289,6 +296,19 @@ begin
|
||||
Result := '--gamestream=off';
|
||||
end;
|
||||
|
||||
{ Firewall scope: the "allowpublicfw" task opens the streaming + console ports on Public networks too
|
||||
(default = Private/Domain only). Forwarded to both `service install` and `web setup`. Returns a
|
||||
LEADING SPACE so it concatenates after the preceding code-substitution param without a gap.
|
||||
(Do NOT write a literal code-constant token in this comment: Inno's brace comments do not nest,
|
||||
so its closing brace would end the comment early and break the [Code] parse.) }
|
||||
function PublicFwParam(Param: String): String;
|
||||
begin
|
||||
if WizardIsTaskSelected('allowpublicfw') then
|
||||
Result := ' --allow-public-network'
|
||||
else
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
#ifdef WithWeb
|
||||
var
|
||||
WebPwPage: TInputQueryWizardPage;
|
||||
|
||||
@@ -41,6 +41,23 @@ foreach ($k in 'LIBCLANG_PATH','CMAKE_POLICY_VERSION_MINIMUM') {
|
||||
else { Write-Warning "env $k not set (run setup-build-env.ps1)" }
|
||||
}
|
||||
|
||||
# All-vendor build when an FFmpeg dev tree is available (BtbN lgpl-shared: include/ + lib/ + bin/):
|
||||
# nvenc alone otherwise. Without amf-qsv a GPU preference pointing at an AMD/Intel adapter makes
|
||||
# every session die at encoder open (NV_ENC_ERR_NO_ENCODE_DEVICE) - the exact "can't connect"
|
||||
# field failure on hybrid boxes.
|
||||
$features = 'nvenc'
|
||||
if (-not $env:FFMPEG_DIR) {
|
||||
$v = [Environment]::GetEnvironmentVariable('FFMPEG_DIR', 'Machine')
|
||||
if ($v) { [Environment]::SetEnvironmentVariable('FFMPEG_DIR', $v, 'Process') }
|
||||
elseif (Test-Path 'C:\Users\Public\ffmpeg\include') { $env:FFMPEG_DIR = 'C:\Users\Public\ffmpeg' }
|
||||
}
|
||||
if ($env:FFMPEG_DIR -and (Test-Path (Join-Path $env:FFMPEG_DIR 'include'))) {
|
||||
$features = 'nvenc,amf-qsv'
|
||||
Write-Host "env : FFMPEG_DIR=$env:FFMPEG_DIR (AMF/QSV enabled)"
|
||||
} else {
|
||||
Write-Warning "no FFMPEG_DIR dev tree - building NVENC-only (AMD/Intel GPU selection will not encode)"
|
||||
}
|
||||
|
||||
# 1. stop the service so the .exe is writable
|
||||
Write-Host "stopping $svc ..."
|
||||
& sc.exe stop $svc | Out-Null
|
||||
@@ -49,9 +66,9 @@ for ($i=0; $i -lt 30 -and (Svc-Running); $i++) { Start-Sleep 1 }
|
||||
# 2. back up the current binary for rollback
|
||||
if (Test-Path $exe) { Copy-Item $exe $bak -Force; Write-Host "backup : $bak" }
|
||||
|
||||
# 3. build (release + nvenc); build env is inherited from Machine scope (setup-build-env.ps1)
|
||||
Write-Host "building: cargo build --release -p punktfunk-host --features nvenc"
|
||||
& cmd.exe /c "call `"$vcvars`" >nul && cargo build --release -p punktfunk-host --features nvenc"
|
||||
# 3. build (release); build env is inherited from Machine scope (setup-build-env.ps1)
|
||||
Write-Host "building: cargo build --release -p punktfunk-host --features $features"
|
||||
& cmd.exe /c "call `"$vcvars`" >nul && cargo build --release -p punktfunk-host --features $features"
|
||||
$built = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if (-not $built) {
|
||||
@@ -61,6 +78,13 @@ if (-not $built) {
|
||||
throw "build failed; previous binary restored and service restarted."
|
||||
}
|
||||
|
||||
# 3b. the AMF/QSV backend link-imports the FFmpeg DLLs - lay them next to the exe (the installer
|
||||
# does the same into {app}); idempotent, and harmless for the NVENC path.
|
||||
if ($features -like '*amf-qsv*') {
|
||||
Copy-Item (Join-Path $env:FFMPEG_DIR 'bin\*.dll') (Split-Path $exe) -Force
|
||||
Write-Host "ffmpeg : runtime DLLs copied next to the exe"
|
||||
}
|
||||
|
||||
# 4. start on the new binary and confirm it stays up
|
||||
Write-Host "build OK - starting $svc ..."
|
||||
& sc.exe start $svc | Out-Null
|
||||
|
||||
Reference in New Issue
Block a user