feat(clients/windows): port the Vulkan session client to Windows — session-always
The punktfunk-session Vulkan client (clients/linux-session, now clients/session) builds and runs on Windows; the WinUI shell spawns it for every stream. Verified live: 10-bit HEVC via Vulkan Video on both AMD (iGPU) and NVIDIA, 5120x1440 at 130 fps / 8 ms end-to-end on the RTX 4090. - pf-ffvk: Windows bindgen branch (FFMPEG_DIR + PF_FFVK_VULKAN_INCLUDE, no pkg-config); provisioning fetches Vulkan-Headers (pinned v1.4.309). - pf-client-core: builds on Windows — WASAPI audio (audio_wasapi.rs, cfg-swapped via #[path], same surface as the PipeWire twin), VAAPI/dmabuf gated inline (chain = vulkan -> software), trust reads the WinUI shell's %APPDATA% stores (parity tests pin both serialized shapes), Settings gains adapter/hdr_enabled (serde-defaulted; Linux stores unaffected). - pf-presenter: builds on Windows — dmabuf module Linux-gated; SDL keyboard grab while captured (Alt+Tab/Win reach the host); pick_device ranks discrete over integrated (device 0 was the iGPU on hybrid boxes — the silent footgun) and honors PUNKTFUNK_VK_ADAPTER (the Settings GPU pick, exported by the session). - run loop: block in one SDL wait woken by input AND decoded frames (a per- session forwarder pushes a FrameWake user event) instead of a 1 ms poll — measured 111%% -> 5%% of a core (NVIDIA), 86%% -> 3.5%% (AMD), stats unchanged. The pump's decode-fence wait became once-per-window sampling (no per-frame pipeline stall; the stat now shows true backlog). - pf-console-ui: builds on Windows (skia-safe msvc prebuilts); font lookup falls through fontconfig aliases to concrete DirectWrite families (Consolas/Segoe UI) — browse/coverflow works, verified against a live host. - WinUI shell: session-always via new src/spawn.rs (GTK spawn.rs port — CREATE_NO_WINDOW, stdout contract, kill handle); the Stream screen is a status card (chips + stage lines from the child's stats). The legacy in-process D3D11VA path stays behind Settings "Streaming engine" / PUNKTFUNK_BUILTIN_ STREAM=1 as the A/B baseline until Phase 8 deletes it. SessionParams.video_caps makes the HDR toggle real. - clients/linux-session renamed to clients/session (builds for both OSes). - CI/MSIX: both workflows build/test both bins with widened path filters; the MSIX ships punktfunk-session.exe. ARM64 session builds --no-default-features (rust-skia has no aarch64-pc-windows-msvc prebuilts; flip when it does). A/B on this box (5120x1440 HEVC vs home-worker-5): NVIDIA Vulkan 130 fps / 8 ms e2e / 1.6 ms decode — clearly better than the built-in path. The AMD iGPU VCN saturates at ~52 fps where its own D3D11VA does ~70 — Adrenalin Vulkan decode is slower on APU silicon; discrete RDNA validation gates Phase 8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,8 @@ stamps the manifest `ProcessorArchitecture` and names the output. See
|
||||
|
||||
| File | Source |
|
||||
|---|---|
|
||||
| `punktfunk-client.exe` | the release build |
|
||||
| `punktfunk-client.exe` | the release build (the WinUI shell) |
|
||||
| `punktfunk-session.exe` | the release build — the Vulkan session client the shell spawns for every stream (sibling resolution, `src/spawn.rs`). Skia links statically; `vulkan-1.dll` is a GPU-driver component, never bundled. ARM64 builds it `--no-default-features` (no Skia console UI) until rust-skia ships aarch64-pc-windows-msvc prebuilts |
|
||||
| `Microsoft.WindowsAppRuntime.Bootstrap.dll`, `resources.pri` | staged by the client's `build.rs` via `windows-reactor-setup::as_framework_dependent()` |
|
||||
| `SDL3.dll` | auto-staged by the `sdl3` crate |
|
||||
| `avcodec/avformat/avutil/swscale/swresample/...-*.dll` | `FFMPEG_DIR\bin` |
|
||||
|
||||
@@ -67,9 +67,12 @@ $layout = Join-Path $OutDir 'layout'
|
||||
if (Test-Path $layout) { Remove-Item -Recurse -Force $layout }
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $layout 'Assets') | Out-Null
|
||||
|
||||
# binary + auto-staged runtime bits (reactor stages the App SDK bootstrap DLL + resources.pri,
|
||||
# the sdl3 crate stages SDL3.dll — see crate build output).
|
||||
$required = @('punktfunk-client.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
# binaries + auto-staged runtime bits (reactor stages the App SDK bootstrap DLL + resources.pri,
|
||||
# the sdl3 crate stages SDL3.dll — see crate build output). punktfunk-session.exe is the Vulkan
|
||||
# session client the shell spawns for every stream (sibling resolution — see clients/windows/
|
||||
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
|
||||
# adds no DLLs of its own.
|
||||
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
|
||||
foreach ($f in $required) {
|
||||
$src = Join-Path $TargetDir $f
|
||||
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
|
||||
|
||||
@@ -217,6 +217,12 @@ fn connect_with(
|
||||
set_status: &AsyncSetState<String>,
|
||||
opts: ConnectOpts,
|
||||
) {
|
||||
// Session-always: every stream runs in the spawned punktfunk-session Vulkan binary.
|
||||
// The in-process D3D11VA path below stays reachable via the "Streaming engine"
|
||||
// setting / PUNKTFUNK_BUILTIN_STREAM=1 as the A/B baseline until its deletion.
|
||||
if !super::use_builtin_stream(ctx) {
|
||||
return connect_spawn(ctx, target, pin, set_screen, set_status, opts);
|
||||
}
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
let gamepad_pref = match GamepadPref::from_name(&s.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(),
|
||||
@@ -330,6 +336,122 @@ fn connect_with(
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn-mode connect: run the stream in the punktfunk-session binary and translate its
|
||||
/// stdout contract into the same navigation the in-process event loop drove. The child
|
||||
/// NEVER connects unpinned — a stored/ceremony pin, else the host's advertised
|
||||
/// fingerprint (TOFU: persisted once the child reports ready, which proves the host
|
||||
/// really holds that identity, mirroring the GTK shell); no fingerprint at all routes to
|
||||
/// the PIN ceremony.
|
||||
fn connect_spawn(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: &Target,
|
||||
pin: Option<[u8; 32]>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
opts: ConnectOpts,
|
||||
) {
|
||||
let tofu = pin.is_none();
|
||||
let fp_hex = pin.map(|p| trust::hex(&p)).or_else(|| {
|
||||
target
|
||||
.fp_hex
|
||||
.clone()
|
||||
.filter(|f| trust::parse_hex32(f).is_some())
|
||||
});
|
||||
let Some(fp_hex) = fp_hex else {
|
||||
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||
set_screen.call(Screen::Pair);
|
||||
return;
|
||||
};
|
||||
|
||||
// A fresh child slot per spawn, installed where Disconnect/Cancel can reach it.
|
||||
let child = crate::spawn::SessionChild::default();
|
||||
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||
ctx.shared.stats_line.lock().unwrap().clear();
|
||||
set_status.call(String::new());
|
||||
set_screen.call(if opts.awaiting_approval {
|
||||
Screen::RequestAccess
|
||||
} else {
|
||||
Screen::Connecting
|
||||
});
|
||||
|
||||
let persist_paired = opts.persist_paired;
|
||||
let cancel = opts.cancel;
|
||||
let wake_on_fail = opts.wake_on_fail;
|
||||
let ctx2 = ctx.clone();
|
||||
let shared = ctx.shared.clone();
|
||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||
let target = target.clone();
|
||||
// The closure owns `target`/`fp_hex`; the call itself borrows copies.
|
||||
let (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
|
||||
let spawned = crate::spawn::spawn_session(
|
||||
&addr,
|
||||
port,
|
||||
&fp_arg,
|
||||
opts.connect_timeout.as_secs(),
|
||||
child,
|
||||
move |event| {
|
||||
use crate::spawn::SpawnEvent;
|
||||
// A cancelled request-access connect that resolved late: tear down silently —
|
||||
// Cancel already killed the child and returned the UI to the host list.
|
||||
if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
SpawnEvent::Ready => {
|
||||
if persist_paired || tofu {
|
||||
// Request-access: the operator approved this device — record the
|
||||
// host PAIRED so future connects are silent. Plain TOFU persists
|
||||
// it *unpaired* (pinned): the child connected pinned to the
|
||||
// advertised fingerprint, so ready proves the host holds it.
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
paired: persist_paired,
|
||||
mac: target.mac.clone(),
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
||||
SpawnEvent::Exited { error, ended } => {
|
||||
match error {
|
||||
Some((msg, true)) => {
|
||||
// Pinned-fingerprint mismatch / pairing required → re-pair via
|
||||
// the PIN screen. The host ANSWERED, so never the wake fallback.
|
||||
st.call(msg);
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
}
|
||||
Some((_, false)) if wake_on_fail => {
|
||||
// The dial-first attempt to a non-advertising host failed — it
|
||||
// may genuinely be asleep. NOW wake and wait.
|
||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
||||
}
|
||||
Some((msg, false)) => {
|
||||
st.call(msg);
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
// `ended` = the host ended the session (banner); a clean exit
|
||||
// (user closed the stream window / Disconnect) returns silently.
|
||||
None => {
|
||||
st.call(ended.unwrap_or_default());
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Err(e) = spawned {
|
||||
set_status.call(e);
|
||||
set_screen.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
|
||||
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||
/// operator approves this device in its console (or web UI), showing a cancelable "waiting"
|
||||
/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is
|
||||
@@ -488,12 +610,15 @@ pub(crate) fn request_access_page(
|
||||
button("Cancel")
|
||||
.icon(Symbol::Cancel)
|
||||
.on_click(move || {
|
||||
// Return the UI immediately; the parked connect is blocking with no abort, so trip
|
||||
// the flag this request's event loop captured — it then tears down silently when
|
||||
// the connect finally resolves (see ConnectOpts::cancel).
|
||||
// Return the UI immediately; trip the flag this request's event loop
|
||||
// captured so it tears down silently when the connect resolves (see
|
||||
// ConnectOpts::cancel). Spawn mode: killing the parked child IS the abort
|
||||
// (builtin mode's in-process connect is blocking with none — it just
|
||||
// resolves/times out later).
|
||||
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
|
||||
c.store(true, Ordering::SeqCst);
|
||||
}
|
||||
ctx.shared.session.lock().unwrap().kill();
|
||||
ss.call(Screen::Hosts);
|
||||
})
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
|
||||
@@ -118,6 +118,12 @@ pub(crate) struct Shared {
|
||||
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
||||
/// by the HUD poll thread to drive the overlay.
|
||||
pub(crate) stats: Mutex<Stats>,
|
||||
/// The live session child (spawn mode) — the status page's Disconnect and the
|
||||
/// request-access Cancel kill it. A FRESH handle is installed per spawn.
|
||||
pub(crate) session: Mutex<crate::spawn::SessionChild>,
|
||||
/// Latest `stats:` line from the session child (spawn mode), already formatted;
|
||||
/// mirrored into the HUD sample for the session status page.
|
||||
pub(crate) stats_line: Mutex<String>,
|
||||
/// Cancel flag for the in-flight "request access" connect. A FRESH flag is installed per
|
||||
/// request: the waiting screen's Cancel button reads it back from here and sets it, and that
|
||||
/// request's event loop (which captured the same `Arc` at spawn) then tears down silently when
|
||||
@@ -136,6 +142,15 @@ pub struct AppCtx {
|
||||
pub(crate) shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
/// The legacy in-process streaming path (SwapChainPanel + D3D11VA) instead of the
|
||||
/// spawned punktfunk-session window: the Settings "Streaming engine" pick, or the
|
||||
/// `PUNKTFUNK_BUILTIN_STREAM=1` env override. A temporary A/B knob — both go away with
|
||||
/// the legacy path once the Vulkan session is fully validated.
|
||||
pub(crate) fn use_builtin_stream(ctx: &AppCtx) -> bool {
|
||||
std::env::var_os("PUNKTFUNK_BUILTIN_STREAM").is_some_and(|v| v == "1")
|
||||
|| ctx.settings.lock().unwrap().engine == "builtin"
|
||||
}
|
||||
|
||||
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||
let ctx = Arc::new(AppCtx {
|
||||
identity,
|
||||
@@ -254,6 +269,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
captured: crate::input::is_captured(),
|
||||
visible: crate::input::hud_visible(),
|
||||
present: crate::render::present_stats(),
|
||||
stats_line: shared.stats_line.lock().unwrap().clone(),
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
@@ -403,6 +419,11 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
Screen::Help => help::help_page(&set_screen),
|
||||
Screen::Pair => component(pair::pair_page, svc),
|
||||
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
|
||||
// Spawn mode (the default): the stream runs in the punktfunk-session child's own
|
||||
// window; this screen is a status page (no hooks — inline is sound). The legacy
|
||||
// in-process SwapChainPanel page stays behind the "Streaming engine" setting /
|
||||
// PUNKTFUNK_BUILTIN_STREAM=1.
|
||||
Screen::Stream if !use_builtin_stream(ctx) => stream::session_page(ctx, &hud),
|
||||
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
|
||||
};
|
||||
|
||||
|
||||
@@ -19,11 +19,18 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
/// `0` = the display's native refresh, resolved at connect.
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
/// Decode backend presets: `(stored value, display label)`.
|
||||
// A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows
|
||||
// Automatic — which is exactly how the session's decoder chain reads that value.
|
||||
const DECODERS: &[(&str, &str)] = &[
|
||||
("auto", "Automatic (GPU, fall back to CPU)"),
|
||||
("hardware", "Hardware (GPU / D3D11VA)"),
|
||||
("vulkan", "Hardware (GPU / Vulkan Video)"),
|
||||
("software", "Software (CPU)"),
|
||||
];
|
||||
/// Temporary A/B knob (see `Settings::engine`) — deleted with the legacy path.
|
||||
const ENGINES: &[(&str, &str)] = &[
|
||||
("", "Vulkan session window (recommended)"),
|
||||
("builtin", "Built-in D3D11VA (legacy)"),
|
||||
];
|
||||
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
||||
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
||||
@@ -182,8 +189,8 @@ pub(crate) fn settings_page(
|
||||
s.decoder = DECODERS[i].0.to_string();
|
||||
})
|
||||
.tooltip(
|
||||
"Hardware decode (D3D11VA) is far lighter than software \u{2014} keep it on Automatic \
|
||||
unless debugging.",
|
||||
"Hardware decode (Vulkan Video) is far lighter than software \u{2014} keep it on \
|
||||
Automatic unless debugging.",
|
||||
);
|
||||
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
|
||||
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
|
||||
@@ -239,6 +246,15 @@ pub(crate) fn settings_page(
|
||||
"Advertise 10-bit HDR10 so the host upgrades HDR content. Needs a display in HDR mode; \
|
||||
SDR content is unaffected.",
|
||||
);
|
||||
let (eng_names, eng_i) = presets(ENGINES, |v| *v == s.engine);
|
||||
let engine_combo = setting_combo(ctx, "Streaming engine", eng_names, eng_i, |s, i| {
|
||||
s.engine = ENGINES[i].0.to_string();
|
||||
})
|
||||
.tooltip(
|
||||
"Temporary: compare the Vulkan session window against the legacy in-process \
|
||||
D3D11VA presenter. Applies to the next stream. This option goes away once the \
|
||||
Vulkan path is fully validated.",
|
||||
);
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently connected;
|
||||
@@ -340,6 +356,7 @@ pub(crate) fn settings_page(
|
||||
bitrate_box.into(),
|
||||
hdr_toggle.into(),
|
||||
hud_toggle.into(),
|
||||
engine_combo.into(),
|
||||
]);
|
||||
controls
|
||||
}),
|
||||
|
||||
@@ -18,7 +18,7 @@ use windows_reactor::*;
|
||||
/// One HUD refresh: the latest session stats, the input hooks' capture state, and the render
|
||||
/// thread's display-side window. Mirrored into root state by the poll thread (`pf-hud`) and
|
||||
/// passed down as a prop.
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub(crate) struct HudSample {
|
||||
pub(crate) stats: Stats,
|
||||
pub(crate) captured: bool,
|
||||
@@ -30,6 +30,9 @@ pub(crate) struct HudSample {
|
||||
/// The render thread's glass-side window (presents/s, skips, end-to-end p50/p95, display
|
||||
/// stage p50) — see [`crate::render::present_stats`].
|
||||
pub(crate) present: crate::render::PresentStats,
|
||||
/// Spawn mode: the session child's latest formatted `stats:` line, for the status
|
||||
/// page. Empty in builtin mode / before the first window.
|
||||
pub(crate) stats_line: String,
|
||||
}
|
||||
|
||||
/// Props for the stream page: the services plus the live HUD sample that drives the overlay
|
||||
@@ -155,6 +158,108 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
grid(layers).into()
|
||||
}
|
||||
|
||||
/// Spawn mode's Stream screen: the stream runs in the punktfunk-session child's own
|
||||
/// window, so the shell shows a status card in the app's card language — monogram +
|
||||
/// host header, the child's live `stats:` line as a chip row + stage lines, the
|
||||
/// in-window shortcuts, and a Disconnect that kills the child (its exit event routes
|
||||
/// the app back to the host list, same as the child's window closing). No hooks.
|
||||
pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element {
|
||||
use super::style::{avatar, card, pill, Pill};
|
||||
let host = ctx.shared.target.lock().unwrap().name.clone();
|
||||
let title = if host.is_empty() {
|
||||
"Streaming".to_string()
|
||||
} else {
|
||||
format!("Streaming to {host}")
|
||||
};
|
||||
|
||||
// Header: monogram + title + the one thing worth knowing (where the video went).
|
||||
let header: Element = grid((
|
||||
avatar(&host)
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
vstack((
|
||||
text_block(&title).font_size(18.0).semibold(),
|
||||
text_block("The stream runs in its own window \u{2014} click it to capture input.")
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(2.0)
|
||||
.grid_column(1)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.margin(edges(12.0, 0.0, 0.0, 0.0)),
|
||||
))
|
||||
.columns([GridLength::Auto, GridLength::Star(1.0)])
|
||||
.into();
|
||||
|
||||
// The child prints one formatted stats line per 1 s window:
|
||||
// "<mode> · <fps> · <Mb/s> · <path> [· HDR] | e2e … | …" — the first segment becomes
|
||||
// a chip row (the decode path gets the status colour), the rest dim stage lines.
|
||||
let mut body: Vec<Element> = vec![header];
|
||||
if hud.stats_line.is_empty() {
|
||||
body.push(
|
||||
text_block("Waiting for the first stats window\u{2026}")
|
||||
.font_size(11.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
let mut segments = hud.stats_line.split(" | ");
|
||||
if let Some(first) = segments.next() {
|
||||
let chips: Vec<Element> = first
|
||||
.split(" \u{00B7} ")
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(|c| {
|
||||
let kind = match c {
|
||||
"vulkan" | "vaapi" => Pill::Good,
|
||||
"software" => Pill::Info,
|
||||
_ => Pill::Neutral,
|
||||
};
|
||||
pill(c, kind).into()
|
||||
})
|
||||
.collect();
|
||||
body.push(hstack(chips).spacing(6.0).into());
|
||||
}
|
||||
for seg in segments {
|
||||
body.push(
|
||||
text_block(seg.trim())
|
||||
.font_size(11.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
body.push(
|
||||
text_block(
|
||||
"Ctrl+Alt+Shift+Q releases input \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \
|
||||
Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen",
|
||||
)
|
||||
.font_size(11.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.margin(edges(0.0, 4.0, 0.0, 0.0))
|
||||
.into(),
|
||||
);
|
||||
body.push({
|
||||
let ctx = ctx.clone();
|
||||
button("Disconnect")
|
||||
.icon(Symbol::Cancel)
|
||||
.on_click(move || {
|
||||
// Kill the child; its exit event (the reader thread) navigates to the
|
||||
// host list, exactly like the session window closing.
|
||||
ctx.shared.session.lock().unwrap().kill();
|
||||
})
|
||||
.margin(edges(0.0, 6.0, 0.0, 0.0))
|
||||
.into()
|
||||
});
|
||||
|
||||
// One centred card, sized like the app's dialogs.
|
||||
border(card(vstack(body).spacing(12.0).width(520.0)))
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// How long the stream-start shortcut banner stays up (seconds of session uptime).
|
||||
const START_HINT_SECS: u32 = 6;
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ mod render;
|
||||
#[cfg(windows)]
|
||||
mod session;
|
||||
#[cfg(windows)]
|
||||
mod spawn;
|
||||
#[cfg(windows)]
|
||||
mod trust;
|
||||
#[cfg(windows)]
|
||||
mod video;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! The shell↔session handoff: streams run in the spawned `punktfunk-session` Vulkan
|
||||
//! binary (session-always, mirroring the GTK shell's `clients/linux/src/spawn.rs`). This
|
||||
//! module owns the child's lifecycle plumbing — spawned with CREATE_NO_WINDOW (the
|
||||
//! session keeps the console subsystem for its stdout contract; without the flag a GUI
|
||||
//! parent would pop a console window), its stdout contract parsed into typed
|
||||
//! [`SpawnEvent`]s a reader thread hands to the app's navigation closure: spinner until
|
||||
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected`
|
||||
//! routed to the re-pair PIN ceremony, `stats:` lines to the session status page.
|
||||
//!
|
||||
//! The legacy in-process D3D11VA presenter remains reachable via the Settings
|
||||
//! "Streaming engine" pick or `PUNKTFUNK_BUILTIN_STREAM=1` (`app::use_builtin_stream`) —
|
||||
//! the A/B baseline until its deletion.
|
||||
|
||||
use std::io::BufRead as _;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// One parsed event from the session child.
|
||||
pub(crate) enum SpawnEvent {
|
||||
/// The child presented its first frame (its window is up and streaming).
|
||||
Ready,
|
||||
/// One `stats:` line, already human-formatted by the session (per 1 s window).
|
||||
Stats(String),
|
||||
/// The child exited (stdout EOF + reap; a kill lands here too). `error`/`ended`
|
||||
/// carry the contract lines seen on the way out, when any (the exit code is logged
|
||||
/// by the reader; routing keys off the lines, which say strictly more).
|
||||
Exited {
|
||||
error: Option<(String, bool)>,
|
||||
ended: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Kills the spawned session child (the Disconnect button, request-access Cancel). Safe
|
||||
/// to call any time; a child that already exited is a no-op. A FRESH handle is installed
|
||||
/// per spawn (`Shared::session`) so a stale handle can never kill a newer session.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct SessionChild(Arc<Mutex<Option<Child>>>);
|
||||
|
||||
impl SessionChild {
|
||||
pub(crate) fn kill(&self) {
|
||||
if let Some(child) = self.0.lock().unwrap().as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed stdout line of the session contract; `None` for anything unrecognized.
|
||||
enum ChildLine {
|
||||
Ready,
|
||||
Error { msg: String, trust_rejected: bool },
|
||||
Ended(String),
|
||||
Stats(String),
|
||||
}
|
||||
|
||||
fn parse_line(line: &str) -> Option<ChildLine> {
|
||||
if let Some(stats) = line.strip_prefix("stats: ") {
|
||||
return Some(ChildLine::Stats(stats.to_string()));
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
if v.get("ready").and_then(|r| r.as_bool()) == Some(true) {
|
||||
return Some(ChildLine::Ready);
|
||||
}
|
||||
if let Some(msg) = v.get("error").and_then(|m| m.as_str()) {
|
||||
return Some(ChildLine::Error {
|
||||
msg: msg.to_string(),
|
||||
trust_rejected: v.get("trust_rejected").and_then(|t| t.as_bool()) == Some(true),
|
||||
});
|
||||
}
|
||||
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
|
||||
return Some(ChildLine::Ended(msg.to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The session binary: installed next to the shell (the MSIX layout and dev
|
||||
/// `target\…` runs both land on the sibling), else `PATH`.
|
||||
pub(crate) fn session_binary() -> std::path::PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let sibling = exe.with_file_name("punktfunk-session.exe");
|
||||
if sibling.exists() {
|
||||
return sibling;
|
||||
}
|
||||
}
|
||||
"punktfunk-session".into()
|
||||
}
|
||||
|
||||
/// Spawn the session binary for a connect with `fp_hex` pinned and feed its lifecycle to
|
||||
/// `on_event` from a reader thread. The child is parked in `slot` so Disconnect/Cancel
|
||||
/// can kill it. `Err` = the spawn itself failed (binary missing?) — surfaced as a
|
||||
/// connect error by the caller.
|
||||
pub(crate) fn spawn_session(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
fp_hex: &str,
|
||||
connect_timeout_secs: u64,
|
||||
slot: SessionChild,
|
||||
mut on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.arg("--connect")
|
||||
.arg(format!("{addr}:{port}"))
|
||||
.arg("--fp")
|
||||
.arg(fp_hex)
|
||||
.arg("--connect-timeout")
|
||||
.arg(connect_timeout_secs.to_string())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||
tracing::info!(host = %addr, port, "session binary spawned");
|
||||
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
// Park the child where the kill handle (and the reader, for the final reap) reach it.
|
||||
*slot.0.lock().unwrap() = Some(child);
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-session-io".into())
|
||||
.spawn(move || {
|
||||
let mut error: Option<(String, bool)> = None;
|
||||
let mut ended: Option<String> = None;
|
||||
for line in std::io::BufReader::new(stdout).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
match parse_line(&line) {
|
||||
Some(ChildLine::Ready) => on_event(SpawnEvent::Ready),
|
||||
Some(ChildLine::Stats(s)) => on_event(SpawnEvent::Stats(s)),
|
||||
Some(ChildLine::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => error = Some((msg, trust_rejected)),
|
||||
Some(ChildLine::Ended(msg)) => ended = Some(msg),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// EOF — reap the child (killed-by-Disconnect lands here too; -1 = no code).
|
||||
let code = slot
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.and_then(|mut c| c.wait().ok())
|
||||
.and_then(|s| s.code())
|
||||
.unwrap_or(-1);
|
||||
tracing::info!(code, "session binary exited");
|
||||
on_event(SpawnEvent::Exited { error, ended });
|
||||
})
|
||||
.map_err(|e| format!("session reader thread: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_the_stdout_contract() {
|
||||
assert!(matches!(
|
||||
parse_line("{\"ready\":true}"),
|
||||
Some(ChildLine::Ready)
|
||||
));
|
||||
match parse_line("{\"error\":\"no route\",\"trust_rejected\":false}") {
|
||||
Some(ChildLine::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => {
|
||||
assert_eq!(msg, "no route");
|
||||
assert!(!trust_rejected);
|
||||
}
|
||||
_ => panic!("error line"),
|
||||
}
|
||||
match parse_line("{\"error\":\"pin\",\"trust_rejected\":true}") {
|
||||
Some(ChildLine::Error { trust_rejected, .. }) => assert!(trust_rejected),
|
||||
_ => panic!("trust line"),
|
||||
}
|
||||
match parse_line("{\"ended\":\"Host ended the session\"}") {
|
||||
Some(ChildLine::Ended(m)) => assert_eq!(m, "Host ended the session"),
|
||||
_ => panic!("ended line"),
|
||||
}
|
||||
// Stats lines become Stats events; stray output never becomes an event.
|
||||
match parse_line("stats: 1280\u{00D7}800@60 \u{00B7} 60 fps") {
|
||||
Some(ChildLine::Stats(s)) => assert!(s.starts_with("1280")),
|
||||
_ => panic!("stats line"),
|
||||
}
|
||||
assert!(parse_line("").is_none());
|
||||
assert!(parse_line("{\"other\":1}").is_none());
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,12 @@ pub struct Settings {
|
||||
/// Show the stats/info overlay (HUD) over the stream.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_hud: bool,
|
||||
/// Streaming engine: `""` = the punktfunk-session Vulkan window (the default),
|
||||
/// `"builtin"` = the legacy in-process D3D11VA presenter. A temporary A/B knob —
|
||||
/// removed with the legacy path once the Vulkan session is fully validated.
|
||||
/// `default` so pre-existing stores load.
|
||||
#[serde(default)]
|
||||
pub engine: String,
|
||||
}
|
||||
|
||||
fn default_codec() -> String {
|
||||
@@ -222,6 +228,7 @@ impl Default for Settings {
|
||||
codec: "auto".into(),
|
||||
adapter: String::new(),
|
||||
show_hud: true,
|
||||
engine: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user