feat(clients/windows): single-window handoff, shared settings store, console-UI surfacing
UX polish batch 1 (clients/windows/ui-polish-plan.md, workstreams W0/A1/B/C/E):
- W0: the shell's duplicated trust/settings structs are gone — src/trust.rs
re-exports pf-client-core's, so the shell and the spawned session binary share
ONE Settings shape over client-windows-settings.json. Fixes real bugs: the
shell's saves no longer drop session-side fields; the stats-overlay toggle
(show_hud -> show_stats, serde alias migrates old files) and the forwarded-
controller pick now actually reach the spawned session. The "Streaming engine"
Settings combo is gone (PUNKTFUNK_BUILTIN_STREAM=1 stays as the dev A/B knob).
- Forwarded-controller pinning by stable key (vid:pid:name, pf-client-core's
format): persisted as forward_pad, applied by the shell's own service at
startup AND by the session binary in session_params (both OSes — the session
never applied the pin before).
- A1 single window: the shell hides itself when the spawned session window
presents its first frame ({"ready":true}) and restores + foregrounds on the
child's exit — exactly one visible Punktfunk window at any time. Restore runs
before the request-access cancel gate so a Ready/Cancel race can't strand the
shell hidden.
- C console UI: punktfunk-session --browse gains --json-status (ready when the
library window presents; error line on a failed start), and the shell
surfaces it — "Open console UI" on paired hosts' menus, plus a controller-
detected hint card targeting the most recent paired host (x64 only; aarch64
ships no skia ui feature). Browse spawns hide/restore the shell like streams.
- B responsive: minimum window size (420x360); the hosts header collapses to
icon-only buttons below 700 px; the session status card shrinks instead of
clipping (max_width); busy pages, help actions, licenses and long labels wrap.
- E polish: session exe gets the app icon (winresource + WM_SETICON via the new
pf-presenter win32.rs); both exes share an explicit AppUserModelID on
unpackaged runs (packaged identity wins) so the windows group as one taskbar
app across the visibility handoff; "Start streams fullscreen" toggle passes
--fullscreen (GTK parity); connects stamp KnownHost::last_used; the connect
target is stashed for every route so "Connecting to X" always names the host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+3
@@ -2827,6 +2827,7 @@ dependencies = [
|
|||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"sdl3",
|
"sdl3",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3041,6 +3042,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"winresource",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3053,6 +3055,7 @@ dependencies = [
|
|||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"opus",
|
"opus",
|
||||||
|
"pf-client-core",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"sdl3",
|
"sdl3",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -31,3 +31,8 @@ serde_json = { version = "1", optional = true }
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# Embeds the app icon as an exe resource (build.rs) — Windows hosts only (rc.exe from
|
||||||
|
# the SDK); same pattern as clients/windows.
|
||||||
|
[target.'cfg(windows)'.build-dependencies]
|
||||||
|
winresource = "0.1"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! Embed the Windows version-info + icon resources into `punktfunk-session.exe`. The
|
||||||
|
//! icon drives Explorer, and `pf-presenter`'s `win32::stamp_window_icon` loads it by
|
||||||
|
//! ordinal 1 onto the SDL window's title bar / taskbar / Alt-Tab (SDL's own window-class
|
||||||
|
//! icon is the generic default).
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// cfg(windows) is the HOST (skips Linux/macOS builds of this cross-platform binary);
|
||||||
|
// CARGO_CFG_WINDOWS is the TARGET (x64 and cross-compiled ARM64 both pass).
|
||||||
|
#[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()
|
||||||
|
// Ordinal 1 — pf-presenter's win32.rs loads it by this id for WM_SETICON.
|
||||||
|
.set_icon_with_id(icon, "1")
|
||||||
|
.compile()
|
||||||
|
.expect("embed windows icon resource");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,11 +55,14 @@ pub fn run(target: &str) -> u8 {
|
|||||||
shared.set_phase(LibraryPhase::PairFirst);
|
shared.set_phase(LibraryPhase::PairFirst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `--json-status`: a shell parent is reading stdout (the WinUI shell hides itself on
|
||||||
|
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
||||||
|
let json_status = arg_flag("--json-status");
|
||||||
let opts = pf_presenter::SessionOpts {
|
let opts = pf_presenter::SessionOpts {
|
||||||
window_title: format!("Punktfunk · {host_label}"),
|
window_title: format!("Punktfunk · {host_label}"),
|
||||||
fullscreen: fullscreen_mode(),
|
fullscreen: fullscreen_mode(),
|
||||||
print_stats: settings.show_stats || arg_flag("--stats"),
|
print_stats: settings.show_stats || arg_flag("--stats"),
|
||||||
json_status: false, // browse has no shell parent reading stdout
|
json_status,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||||
})),
|
})),
|
||||||
@@ -101,6 +104,11 @@ pub fn run(target: &str) -> u8 {
|
|||||||
match result {
|
match result {
|
||||||
Ok(()) => 0,
|
Ok(()) => 0,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// The shell contract's terminal line (a clean quit needs none — stdout EOF
|
||||||
|
// already routes the shell back to its host list silently).
|
||||||
|
if json_status {
|
||||||
|
crate::session_main::json_line("error", &format!("{e:#}"), Some(false));
|
||||||
|
}
|
||||||
eprintln!("browse: {e:#}");
|
eprintln!("browse: {e:#}");
|
||||||
crate::session_main::EXIT_PRESENTER_FAILED
|
crate::session_main::EXIT_PRESENTER_FAILED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ mod session_main {
|
|||||||
force_software: Arc<AtomicBool>,
|
force_software: Arc<AtomicBool>,
|
||||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||||
) -> SessionParams {
|
) -> SessionParams {
|
||||||
|
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
||||||
|
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
||||||
|
// process. Applied per params-build (idempotent; browse re-launches included) so
|
||||||
|
// it lands before the session attaches. Empty = automatic (most recent).
|
||||||
|
if !settings.forward_pad.is_empty() {
|
||||||
|
gamepad.set_pinned(Some(settings.forward_pad.clone()));
|
||||||
|
}
|
||||||
let mode = Mode {
|
let mode = Mode {
|
||||||
width: if settings.width == 0 {
|
width: if settings.width == 0 {
|
||||||
native.width
|
native.width
|
||||||
@@ -145,8 +152,9 @@ mod session_main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
||||||
/// the minimal rules a reason string can need).
|
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
|
||||||
fn json_line(key: &str, msg: &str, trust_rejected: Option<bool>) {
|
/// failure through the same contract when spawned with `--json-status`.
|
||||||
|
pub(crate) fn json_line(key: &str, msg: &str, trust_rejected: Option<bool>) {
|
||||||
let escaped: String = msg
|
let escaped: String = msg
|
||||||
.chars()
|
.chars()
|
||||||
.flat_map(|c| match c {
|
.flat_map(|c| match c {
|
||||||
@@ -243,7 +251,7 @@ mod session_main {
|
|||||||
let Some(target) = arg_value("--connect") else {
|
let Some(target) = arg_value("--connect") else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
|
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
|
||||||
\x20 punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]\n\
|
\x20 punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen] [--json-status]\n\
|
||||||
\n\
|
\n\
|
||||||
Streams from a paired punktfunk host in a Vulkan window; --browse opens the\n\
|
Streams from a paired punktfunk host in a Vulkan window; --browse opens the\n\
|
||||||
console game library instead (paired hosts only). Pair first via the\n\
|
console game library instead (paired hosts only). Pair first via the\n\
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ path = "src/main.rs"
|
|||||||
# The protocol core, linked directly (no C ABI) — same as the GTK Linux client. NativeClient
|
# The protocol core, linked directly (no C ABI) — same as the GTK Linux client. NativeClient
|
||||||
# is Sync (mutexed plane receivers), so it drops into a UI app cleanly.
|
# is Sync (mutexed plane receivers), so it drops into a UI app cleanly.
|
||||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||||
|
# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the
|
||||||
|
# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library
|
||||||
|
# data model (fetch + art pipeline) behind the library page.
|
||||||
|
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||||
|
|
||||||
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
||||||
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
||||||
@@ -33,6 +37,9 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
|||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Graphics_Dxgi",
|
"Win32_Graphics_Dxgi",
|
||||||
"Win32_Graphics_Dxgi_Common",
|
"Win32_Graphics_Dxgi_Common",
|
||||||
|
# GetCurrentPackageFullName — the packaged-run probe guarding the explicit
|
||||||
|
# AppUserModelID (main.rs; MSIX identity must win over the dev-run tag).
|
||||||
|
"Win32_Storage_Packaging_Appx",
|
||||||
"Win32_Graphics_Direct3D",
|
"Win32_Graphics_Direct3D",
|
||||||
"Win32_Graphics_Direct3D11",
|
"Win32_Graphics_Direct3D11",
|
||||||
"Win32_Graphics_Direct3D_Fxc",
|
"Win32_Graphics_Direct3D_Fxc",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ fn initiate_opts(
|
|||||||
set_status: &AsyncSetState<String>,
|
set_status: &AsyncSetState<String>,
|
||||||
wake_on_fail: bool,
|
wake_on_fail: bool,
|
||||||
) {
|
) {
|
||||||
|
// Every route reads the target back for its screen copy ("Connecting to X",
|
||||||
|
// "Streaming to X") — stash it up front, not just on the pairing route.
|
||||||
|
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
let known = KnownHosts::load();
|
let known = KnownHosts::load();
|
||||||
let pin = target
|
let pin = target
|
||||||
.fp_hex
|
.fp_hex
|
||||||
@@ -292,10 +295,12 @@ fn connect_with(
|
|||||||
port: target.port,
|
port: target.port,
|
||||||
fp_hex: trust::hex(&fingerprint),
|
fp_hex: trust::hex(&fingerprint),
|
||||||
paired: persist_paired,
|
paired: persist_paired,
|
||||||
|
last_used: None,
|
||||||
mac: target.mac.clone(),
|
mac: target.mac.clone(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
|
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||||
gamepad.attach(connector.clone());
|
gamepad.attach(connector.clone());
|
||||||
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
|
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
|
||||||
*shared.handoff.lock().unwrap() =
|
*shared.handoff.lock().unwrap() =
|
||||||
@@ -367,6 +372,8 @@ fn connect_spawn(
|
|||||||
let child = crate::spawn::SessionChild::default();
|
let child = crate::spawn::SessionChild::default();
|
||||||
*ctx.shared.session.lock().unwrap() = child.clone();
|
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||||
ctx.shared.stats_line.lock().unwrap().clear();
|
ctx.shared.stats_line.lock().unwrap().clear();
|
||||||
|
ctx.shared.browse.store(false, Ordering::SeqCst);
|
||||||
|
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
|
||||||
set_status.call(String::new());
|
set_status.call(String::new());
|
||||||
set_screen.call(if opts.awaiting_approval {
|
set_screen.call(if opts.awaiting_approval {
|
||||||
Screen::RequestAccess
|
Screen::RequestAccess
|
||||||
@@ -388,9 +395,16 @@ fn connect_spawn(
|
|||||||
port,
|
port,
|
||||||
&fp_arg,
|
&fp_arg,
|
||||||
opts.connect_timeout.as_secs(),
|
opts.connect_timeout.as_secs(),
|
||||||
|
fullscreen,
|
||||||
|
None,
|
||||||
child,
|
child,
|
||||||
move |event| {
|
move |event| {
|
||||||
use crate::spawn::SpawnEvent;
|
use crate::spawn::SpawnEvent;
|
||||||
|
// The child is gone — bring the shell back BEFORE the cancel gate below, so a
|
||||||
|
// Ready that raced a Cancel (and hid the shell) can never strand it hidden.
|
||||||
|
if matches!(event, SpawnEvent::Exited { .. }) {
|
||||||
|
crate::shell_window::restore();
|
||||||
|
}
|
||||||
// A cancelled request-access connect that resolved late: tear down silently —
|
// A cancelled request-access connect that resolved late: tear down silently —
|
||||||
// Cancel already killed the child and returned the UI to the host list.
|
// 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)) {
|
if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
|
||||||
@@ -410,10 +424,15 @@ fn connect_spawn(
|
|||||||
port: target.port,
|
port: target.port,
|
||||||
fp_hex: fp_hex.clone(),
|
fp_hex: fp_hex.clone(),
|
||||||
paired: persist_paired,
|
paired: persist_paired,
|
||||||
|
last_used: None,
|
||||||
mac: target.mac.clone(),
|
mac: target.mac.clone(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
|
// The child presented its first frame — its window is up, so the
|
||||||
|
// shell yields: one visible Punktfunk window at a time. Every exit
|
||||||
|
// path restores it (the `Exited` handling above).
|
||||||
|
crate::shell_window::hide();
|
||||||
ss.call(Screen::Stream);
|
ss.call(Screen::Stream);
|
||||||
}
|
}
|
||||||
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
||||||
@@ -452,6 +471,53 @@ fn connect_spawn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Open console UI": run the gamepad library (`punktfunk-session --browse`) for a
|
||||||
|
/// PAIRED host in the session window. The shell yields exactly like a stream — hidden on
|
||||||
|
/// the library window's `ready`, restored when the child exits (launched titles stream
|
||||||
|
/// in that same window, so the whole couch round-trip happens without the shell).
|
||||||
|
pub(crate) fn open_console(
|
||||||
|
ctx: &Arc<AppCtx>,
|
||||||
|
target: Target,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
let child = crate::spawn::SessionChild::default();
|
||||||
|
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||||
|
ctx.shared.stats_line.lock().unwrap().clear();
|
||||||
|
ctx.shared.browse.store(true, Ordering::SeqCst);
|
||||||
|
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
|
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
|
||||||
|
set_status.call(String::new());
|
||||||
|
set_screen.call(Screen::Connecting);
|
||||||
|
|
||||||
|
let shared = ctx.shared.clone();
|
||||||
|
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||||
|
let spawned =
|
||||||
|
crate::spawn::spawn_browse(&target.addr, target.port, fullscreen, child, move |event| {
|
||||||
|
use crate::spawn::SpawnEvent;
|
||||||
|
match event {
|
||||||
|
SpawnEvent::Ready => {
|
||||||
|
// The library window presented — the shell yields (same one-visible-
|
||||||
|
// window rule as a stream).
|
||||||
|
crate::shell_window::hide();
|
||||||
|
ss.call(Screen::Stream);
|
||||||
|
}
|
||||||
|
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
||||||
|
SpawnEvent::Exited { error, ended } => {
|
||||||
|
crate::shell_window::restore();
|
||||||
|
// Quit from the library (B / closing the window) returns silently;
|
||||||
|
// a failed start surfaces its error line.
|
||||||
|
st.call(error.map(|(msg, _)| msg).or(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
|
/// 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"
|
/// 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
|
/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is
|
||||||
@@ -553,6 +619,7 @@ fn wake_and_connect(
|
|||||||
port: target.port,
|
port: target.port,
|
||||||
fp_hex: fp,
|
fp_hex: fp,
|
||||||
paired: false,
|
paired: false,
|
||||||
|
last_used: None,
|
||||||
mac: target.mac.clone(),
|
mac: target.mac.clone(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ fn shortcuts_reference() -> Element {
|
|||||||
let row = i as i32;
|
let row = i as i32;
|
||||||
children.push(key_chip(keys).grid_row(row).grid_column(0));
|
children.push(key_chip(keys).grid_row(row).grid_column(0));
|
||||||
let action_cell: Element = text_block(*action)
|
let action_cell: Element = text_block(*action)
|
||||||
|
.wrap()
|
||||||
.foreground(ThemeRef::SecondaryText)
|
.foreground(ThemeRef::SecondaryText)
|
||||||
.vertical_alignment(VerticalAlignment::Center)
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
.into();
|
.into();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
|
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
|
||||||
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
||||||
|
|
||||||
use super::connect::{initiate, initiate_waking};
|
use super::connect::{initiate, initiate_waking, open_console};
|
||||||
use super::speed::SpeedState;
|
use super::speed::SpeedState;
|
||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{Screen, Svc, Target};
|
use super::{Screen, Svc, Target};
|
||||||
@@ -12,11 +12,17 @@ use windows_reactor::*;
|
|||||||
|
|
||||||
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
||||||
const MENU_CONNECT: &str = "Connect";
|
const MENU_CONNECT: &str = "Connect";
|
||||||
|
const MENU_CONSOLE: &str = "Open console UI";
|
||||||
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
||||||
const MENU_WAKE: &str = "Wake host";
|
const MENU_WAKE: &str = "Wake host";
|
||||||
const MENU_RENAME: &str = "Rename\u{2026}";
|
const MENU_RENAME: &str = "Rename\u{2026}";
|
||||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||||
|
|
||||||
|
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
||||||
|
/// its Skia `ui` feature on x64 only (no skia prebuilts for aarch64 yet) — the entry
|
||||||
|
/// points compile everywhere but only show where `--browse` can actually run.
|
||||||
|
const CONSOLE_UI_AVAILABLE: bool = cfg!(target_arch = "x86_64");
|
||||||
|
|
||||||
/// Tile-grid metrics: minimum tile width before dropping a column, and the gap between tiles.
|
/// Tile-grid metrics: minimum tile width before dropping a column, and the gap between tiles.
|
||||||
const TILE_MIN_WIDTH: f64 = 320.0;
|
const TILE_MIN_WIDTH: f64 = 320.0;
|
||||||
const TILE_GAP: f64 = 12.0;
|
const TILE_GAP: f64 = 12.0;
|
||||||
@@ -37,6 +43,9 @@ pub(crate) struct HostsProps {
|
|||||||
pub(crate) svc: Svc,
|
pub(crate) svc: Svc,
|
||||||
pub(crate) hosts: Vec<DiscoveredHost>,
|
pub(crate) hosts: Vec<DiscoveredHost>,
|
||||||
pub(crate) status: String,
|
pub(crate) status: String,
|
||||||
|
/// Connected-controller count (root state, mirrored from the gamepad service) — a
|
||||||
|
/// pad plus a paired host surfaces the "Open console UI" hint card.
|
||||||
|
pub(crate) pads: usize,
|
||||||
pub(crate) forget: Option<(String, String)>,
|
pub(crate) forget: Option<(String, String)>,
|
||||||
pub(crate) rename: Option<(String, String)>,
|
pub(crate) rename: Option<(String, String)>,
|
||||||
/// Whether the "Add host" modal is open. Root state (like `forget`/`rename`), not the page's
|
/// Whether the "Add host" modal is open. Root state (like `forget`/`rename`), not the page's
|
||||||
@@ -60,6 +69,7 @@ impl PartialEq for HostsProps {
|
|||||||
self.svc == other.svc
|
self.svc == other.svc
|
||||||
&& self.hosts == other.hosts
|
&& self.hosts == other.hosts
|
||||||
&& self.status == other.status
|
&& self.status == other.status
|
||||||
|
&& self.pads == other.pads
|
||||||
&& self.forget == other.forget
|
&& self.forget == other.forget
|
||||||
&& self.rename == other.rename
|
&& self.rename == other.rename
|
||||||
&& self.show_add == other.show_add
|
&& self.show_add == other.show_add
|
||||||
@@ -93,6 +103,7 @@ fn host_tile(
|
|||||||
text_block(name)
|
text_block(name)
|
||||||
.font_size(15.0)
|
.font_size(15.0)
|
||||||
.semibold()
|
.semibold()
|
||||||
|
.wrap()
|
||||||
.margin(edges(0.0, 12.0, 0.0, 0.0)),
|
.margin(edges(0.0, 12.0, 0.0, 0.0)),
|
||||||
text_block(sub)
|
text_block(sub)
|
||||||
.font_size(12.0)
|
.font_size(12.0)
|
||||||
@@ -258,30 +269,41 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
let window = cx.use_inner_size();
|
let window = cx.use_inner_size();
|
||||||
let content_w = (window.width - 64.0).clamp(TILE_MIN_WIDTH, 1120.0);
|
let content_w = (window.width - 64.0).clamp(TILE_MIN_WIDTH, 1120.0);
|
||||||
let cols = (((content_w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP)).floor() as usize).max(1);
|
let cols = (((content_w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP)).floor() as usize).max(1);
|
||||||
|
// Compact header: below this the three labelled buttons would collide with the title
|
||||||
|
// (the Auto grid column can't shrink), so they drop to icon-only with tooltips.
|
||||||
|
let compact = window.width < 700.0;
|
||||||
|
let header_btn = |label: &str, sym: Symbol| {
|
||||||
|
if compact {
|
||||||
|
button("").icon(sym).tooltip(label).automation_name(label)
|
||||||
|
} else {
|
||||||
|
button(label).icon(sym)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut body: Vec<Element> = Vec::new();
|
let mut body: Vec<Element> = Vec::new();
|
||||||
|
|
||||||
// Header: title block + Settings button.
|
// Header: title block + Add host / Help / Settings.
|
||||||
body.push(
|
body.push(
|
||||||
grid((
|
grid((
|
||||||
vstack((
|
vstack((
|
||||||
text_block("Punktfunk").font_size(30.0).bold(),
|
text_block("Punktfunk").font_size(30.0).bold(),
|
||||||
text_block("Stream from a host on your network.")
|
text_block("Stream from a host on your network.")
|
||||||
|
.wrap()
|
||||||
.foreground(ThemeRef::SecondaryText),
|
.foreground(ThemeRef::SecondaryText),
|
||||||
))
|
))
|
||||||
.spacing(2.0)
|
.spacing(2.0)
|
||||||
.grid_column(0)
|
.grid_column(0)
|
||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
hstack((
|
hstack((
|
||||||
button("Add host").accent().icon(Symbol::Add).on_click({
|
header_btn("Add host", Symbol::Add).accent().on_click({
|
||||||
let sa = set_show_add.clone();
|
let sa = set_show_add.clone();
|
||||||
move || sa.call(true)
|
move || sa.call(true)
|
||||||
}),
|
}),
|
||||||
button("Help").icon(Symbol::Help).on_click({
|
header_btn("Help", Symbol::Help).on_click({
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
move || ss.call(Screen::Help)
|
move || ss.call(Screen::Help)
|
||||||
}),
|
}),
|
||||||
button("Settings").icon(Symbol::Setting).on_click({
|
header_btn("Settings", Symbol::Setting).on_click({
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
move || ss.call(Screen::Settings)
|
move || ss.call(Screen::Settings)
|
||||||
}),
|
}),
|
||||||
@@ -305,6 +327,63 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A controller is connected and a paired host exists: offer the couch experience —
|
||||||
|
// the console (gamepad) UI on the most recently used paired host.
|
||||||
|
if CONSOLE_UI_AVAILABLE && props.pads > 0 {
|
||||||
|
if let Some(k) = known
|
||||||
|
.hosts
|
||||||
|
.iter()
|
||||||
|
.filter(|h| h.paired)
|
||||||
|
.max_by_key(|h| h.last_used.unwrap_or(0))
|
||||||
|
{
|
||||||
|
let target = Target {
|
||||||
|
name: k.name.clone(),
|
||||||
|
addr: k.addr.clone(),
|
||||||
|
port: k.port,
|
||||||
|
fp_hex: Some(k.fp_hex.clone()),
|
||||||
|
pair_optional: false,
|
||||||
|
mac: k.mac.clone(),
|
||||||
|
};
|
||||||
|
let svc = props.svc.clone();
|
||||||
|
body.push(
|
||||||
|
card(
|
||||||
|
grid((
|
||||||
|
vstack((
|
||||||
|
text_block("Controller detected").font_size(14.0).semibold(),
|
||||||
|
text_block(format!(
|
||||||
|
"Browse {}\u{2019}s game library with the gamepad \u{2014} \
|
||||||
|
launches stream in the same window.",
|
||||||
|
k.name
|
||||||
|
))
|
||||||
|
.font_size(12.0)
|
||||||
|
.wrap()
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
|
))
|
||||||
|
.spacing(2.0)
|
||||||
|
.grid_column(0)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
|
button("Open console UI")
|
||||||
|
.accent()
|
||||||
|
.icon(Symbol::Play)
|
||||||
|
.on_click(move || {
|
||||||
|
open_console(
|
||||||
|
&svc.ctx,
|
||||||
|
target.clone(),
|
||||||
|
&svc.set_screen,
|
||||||
|
&svc.set_status,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.grid_column(1)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
|
.margin(edges(12.0, 0.0, 0.0, 0.0)),
|
||||||
|
))
|
||||||
|
.columns([GridLength::Star(1.0), GridLength::Auto]),
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also
|
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also
|
||||||
// being advertised right now shows as Online (and is deduped out of the discovery section).
|
// being advertised right now shows as Online (and is deduped out of the discovery section).
|
||||||
if !known.hosts.is_empty() {
|
if !known.hosts.is_empty() {
|
||||||
@@ -347,7 +426,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
.tooltip("More options")
|
.tooltip("More options")
|
||||||
.automation_name("More options")
|
.automation_name("More options")
|
||||||
.menu_flyout({
|
.menu_flyout({
|
||||||
let mut items = vec![menu_item(MENU_CONNECT), menu_item(MENU_SPEED)];
|
let mut items = vec![menu_item(MENU_CONNECT)];
|
||||||
|
// The gamepad library — paired hosts only (the mgmt API needs the
|
||||||
|
// paired identity) and only where the session ships the console UI.
|
||||||
|
if CONSOLE_UI_AVAILABLE && k.paired {
|
||||||
|
items.push(menu_item(MENU_CONSOLE));
|
||||||
|
}
|
||||||
|
items.push(menu_item(MENU_SPEED));
|
||||||
// Offer an explicit wake only when the host is offline and we have a MAC.
|
// Offer an explicit wake only when the host is offline and we have a MAC.
|
||||||
if can_wake {
|
if can_wake {
|
||||||
items.push(menu_item(MENU_WAKE));
|
items.push(menu_item(MENU_WAKE));
|
||||||
@@ -361,6 +446,9 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
MENU_CONNECT => {
|
MENU_CONNECT => {
|
||||||
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
||||||
}
|
}
|
||||||
|
MENU_CONSOLE => {
|
||||||
|
open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
||||||
|
}
|
||||||
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
||||||
MENU_SPEED => {
|
MENU_SPEED => {
|
||||||
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
|||||||
text_block("punktfunk").font_size(15.0).semibold(),
|
text_block("punktfunk").font_size(15.0).semibold(),
|
||||||
text_block("Licensed under MIT OR Apache-2.0, at your option.")
|
text_block("Licensed under MIT OR Apache-2.0, at your option.")
|
||||||
.font_size(12.0)
|
.font_size(12.0)
|
||||||
|
.wrap()
|
||||||
.foreground(ThemeRef::SecondaryText),
|
.foreground(ThemeRef::SecondaryText),
|
||||||
text_block(APP_LICENSE)
|
text_block(APP_LICENSE)
|
||||||
.font_size(11.0)
|
.font_size(11.0)
|
||||||
|
.wrap()
|
||||||
.foreground(ThemeRef::SecondaryText),
|
.foreground(ThemeRef::SecondaryText),
|
||||||
))
|
))
|
||||||
.spacing(8.0),
|
.spacing(8.0),
|
||||||
@@ -43,6 +45,7 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
|||||||
Windows App SDK (Microsoft) are also linked.",
|
Windows App SDK (Microsoft) are also linked.",
|
||||||
)
|
)
|
||||||
.font_size(12.0)
|
.font_size(12.0)
|
||||||
|
.wrap()
|
||||||
.foreground(ThemeRef::SecondaryText),
|
.foreground(ThemeRef::SecondaryText),
|
||||||
))
|
))
|
||||||
.spacing(8.0),
|
.spacing(8.0),
|
||||||
@@ -53,6 +56,7 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
|||||||
text_block("Rust crates").font_size(15.0).semibold(),
|
text_block("Rust crates").font_size(15.0).semibold(),
|
||||||
text_block(THIRD_PARTY_NOTICES)
|
text_block(THIRD_PARTY_NOTICES)
|
||||||
.font_size(11.0)
|
.font_size(11.0)
|
||||||
|
.wrap()
|
||||||
.foreground(ThemeRef::SecondaryText),
|
.foreground(ThemeRef::SecondaryText),
|
||||||
))
|
))
|
||||||
.spacing(8.0),
|
.spacing(8.0),
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ pub(crate) struct Shared {
|
|||||||
/// only publishes its outcome while its generation is still current, so a test abandoned
|
/// only publishes its outcome while its generation is still current, so a test abandoned
|
||||||
/// mid-run can't overwrite a newer run's result when it finally resolves.
|
/// mid-run can't overwrite a newer run's result when it finally resolves.
|
||||||
pub(crate) speed_gen: std::sync::atomic::AtomicU64,
|
pub(crate) speed_gen: std::sync::atomic::AtomicU64,
|
||||||
|
/// Whether the live session child is a `--browse` console-UI run (vs a stream) — the
|
||||||
|
/// session status page words itself accordingly. Set by each spawn.
|
||||||
|
pub(crate) browse: std::sync::atomic::AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppCtx {
|
pub struct AppCtx {
|
||||||
@@ -143,12 +146,11 @@ pub struct AppCtx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The legacy in-process streaming path (SwapChainPanel + D3D11VA) instead of the
|
/// The legacy in-process streaming path (SwapChainPanel + D3D11VA) instead of the
|
||||||
/// spawned punktfunk-session window: the Settings "Streaming engine" pick, or the
|
/// spawned punktfunk-session window: the `PUNKTFUNK_BUILTIN_STREAM=1` env override — a
|
||||||
/// `PUNKTFUNK_BUILTIN_STREAM=1` env override. A temporary A/B knob — both go away with
|
/// developer A/B knob only (the former Settings "Streaming engine" pick is gone), removed
|
||||||
/// the legacy path once the Vulkan session is fully validated.
|
/// with the legacy path once the Vulkan session is fully validated.
|
||||||
pub(crate) fn use_builtin_stream(ctx: &AppCtx) -> bool {
|
pub(crate) fn use_builtin_stream(_ctx: &AppCtx) -> bool {
|
||||||
std::env::var_os("PUNKTFUNK_BUILTIN_STREAM").is_some_and(|v| v == "1")
|
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<()> {
|
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||||
@@ -158,10 +160,25 @@ pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_react
|
|||||||
gamepad,
|
gamepad,
|
||||||
shared: Arc::new(Shared::default()),
|
shared: Arc::new(Shared::default()),
|
||||||
});
|
});
|
||||||
|
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
|
||||||
|
// whenever such a pad connects) — GTK-shell parity.
|
||||||
|
{
|
||||||
|
let forward = ctx.settings.lock().unwrap().forward_pad.clone();
|
||||||
|
if !forward.is_empty() {
|
||||||
|
ctx.gamepad.set_pinned(Some(forward));
|
||||||
|
}
|
||||||
|
}
|
||||||
apply_window_icon_when_ready();
|
apply_window_icon_when_ready();
|
||||||
App::new()
|
App::new()
|
||||||
.title("Punktfunk")
|
.title("Punktfunk")
|
||||||
.inner_size(1000.0, 720.0)
|
.inner_size(1000.0, 720.0)
|
||||||
|
// A floor under every layout: below this the header/cards would clip rather than
|
||||||
|
// reflow (the pages adapt down to it — see the hosts page's responsive grid).
|
||||||
|
.inner_constraints(InnerConstraints {
|
||||||
|
min_width: Some(420.0),
|
||||||
|
min_height: Some(360.0),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
.backdrop(Backdrop::Mica)
|
.backdrop(Backdrop::Mica)
|
||||||
.render(move |cx| root(cx, &ctx))
|
.render(move |cx| root(cx, &ctx))
|
||||||
}
|
}
|
||||||
@@ -232,6 +249,22 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
||||||
// Which Settings section the NavigationView shows (persists across visits this run).
|
// Which Settings section the NavigationView shows (persists across visits this run).
|
||||||
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
|
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
|
||||||
|
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
||||||
|
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
||||||
|
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
||||||
|
let (pads, set_pads) = cx.use_async_state(0usize);
|
||||||
|
cx.use_effect((), {
|
||||||
|
let (gp, set_pads) = (ctx.gamepad.clone(), set_pads.clone());
|
||||||
|
move || {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pf-pads".into())
|
||||||
|
.spawn(move || loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
set_pads.call(gp.pads().len());
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Continuous LAN discovery (spawned once).
|
// Continuous LAN discovery (spawned once).
|
||||||
cx.use_effect((), {
|
cx.use_effect((), {
|
||||||
@@ -392,6 +425,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
svc,
|
svc,
|
||||||
hosts,
|
hosts,
|
||||||
status,
|
status,
|
||||||
|
pads,
|
||||||
forget,
|
forget,
|
||||||
rename,
|
rename,
|
||||||
show_add,
|
show_add,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
port: target3.port,
|
port: target3.port,
|
||||||
fp_hex: trust::hex(&fp),
|
fp_hex: trust::hex(&fp),
|
||||||
paired: true,
|
paired: true,
|
||||||
|
last_used: None,
|
||||||
mac: target3.mac.clone(),
|
mac: target3.mac.clone(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
|
|||||||
@@ -26,11 +26,6 @@ const DECODERS: &[(&str, &str)] = &[
|
|||||||
("vulkan", "Hardware (GPU / Vulkan Video)"),
|
("vulkan", "Hardware (GPU / Vulkan Video)"),
|
||||||
("software", "Software (CPU)"),
|
("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
|
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||||
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
||||||
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
||||||
@@ -182,6 +177,13 @@ pub(crate) fn settings_page(
|
|||||||
"Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
|
"Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
|
||||||
choice is unavailable.",
|
choice is unavailable.",
|
||||||
);
|
);
|
||||||
|
let fullscreen_toggle = setting_toggle(
|
||||||
|
ctx,
|
||||||
|
"Start streams fullscreen",
|
||||||
|
s.fullscreen_on_stream,
|
||||||
|
|s, on| s.fullscreen_on_stream = on,
|
||||||
|
)
|
||||||
|
.tooltip("The stream window opens fullscreen; F11 or Alt+Enter switches back live.");
|
||||||
|
|
||||||
// --- Video -----------------------------------------------------------------------------
|
// --- Video -----------------------------------------------------------------------------
|
||||||
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
||||||
@@ -246,19 +248,11 @@ pub(crate) fn settings_page(
|
|||||||
"Advertise 10-bit HDR10 so the host upgrades HDR content. Needs a display in HDR mode; \
|
"Advertise 10-bit HDR10 so the host upgrades HDR content. Needs a display in HDR mode; \
|
||||||
SDR content is unaffected.",
|
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 -----------------------------------------------------------------------------
|
// --- Input -----------------------------------------------------------------------------
|
||||||
// Which physical controller forwards as pad 0: automatic = the most recently connected;
|
// Which physical controller forwards as pad 0: automatic = the most recently connected.
|
||||||
// pinning survives until the app exits (Swift/GTK parity).
|
// Persisted by stable key (`Settings::forward_pad`, GTK parity) so the pin survives
|
||||||
|
// restarts AND reaches the spawned session binary, whose service applies the same key.
|
||||||
let pads = ctx.gamepad.pads();
|
let pads = ctx.gamepad.pads();
|
||||||
let (fwd_names, fwd_i) = {
|
let (fwd_names, fwd_i) = {
|
||||||
let mut names = vec!["Automatic (most recent)".to_string()];
|
let mut names = vec!["Automatic (most recent)".to_string()];
|
||||||
@@ -270,26 +264,32 @@ pub(crate) fn settings_page(
|
|||||||
format!("{} \u{00B7} {kind}", p.name)
|
format!("{} \u{00B7} {kind}", p.name)
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
let i = ctx
|
let i = (!s.forward_pad.is_empty())
|
||||||
.gamepad
|
.then(|| pads.iter().position(|p| p.key == s.forward_pad))
|
||||||
.pinned()
|
.flatten()
|
||||||
.and_then(|id| pads.iter().position(|p| p.id == id))
|
|
||||||
.map_or(0, |i| i + 1);
|
.map_or(0, |i| i + 1);
|
||||||
(names, i)
|
(names, i)
|
||||||
};
|
};
|
||||||
let forward_combo = {
|
let forward_combo = {
|
||||||
let svc = ctx.gamepad.clone();
|
let svc = ctx.gamepad.clone();
|
||||||
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect();
|
let ctx2 = ctx.clone();
|
||||||
|
let keys: Vec<String> = pads.iter().map(|p| p.key.clone()).collect();
|
||||||
ComboBox::new(fwd_names)
|
ComboBox::new(fwd_names)
|
||||||
.header("Forwarded controller")
|
.header("Forwarded controller")
|
||||||
.selected_index(fwd_i as i32)
|
.selected_index(fwd_i as i32)
|
||||||
.on_selection_changed(move |i: i32| {
|
.on_selection_changed(move |i: i32| {
|
||||||
let sel = i.max(0) as usize;
|
let sel = i.max(0) as usize;
|
||||||
svc.set_pinned(if sel == 0 {
|
let key = if sel == 0 {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
ids.get(sel - 1).copied()
|
keys.get(sel - 1).cloned()
|
||||||
});
|
};
|
||||||
|
// Apply live (the in-process service, legacy builtin streams) and persist —
|
||||||
|
// the spawned session reads `forward_pad` at connect.
|
||||||
|
svc.set_pinned(key.clone());
|
||||||
|
let mut s = ctx2.settings.lock().unwrap();
|
||||||
|
s.forward_pad = key.unwrap_or_default();
|
||||||
|
s.save();
|
||||||
})
|
})
|
||||||
.tooltip(
|
.tooltip(
|
||||||
"Exactly one controller is forwarded to the host; \u{201C}Automatic\u{201D} \
|
"Exactly one controller is forwarded to the host; \u{201C}Automatic\u{201D} \
|
||||||
@@ -328,9 +328,12 @@ pub(crate) fn settings_page(
|
|||||||
)
|
)
|
||||||
.tooltip("Sends the default microphone to the host's virtual mic source.");
|
.tooltip("Sends the default microphone to the host's virtual mic source.");
|
||||||
|
|
||||||
let hud_toggle = setting_toggle(ctx, "Show the stats overlay (HUD)", s.show_hud, |s, on| {
|
let hud_toggle = setting_toggle(
|
||||||
s.show_hud = on
|
ctx,
|
||||||
})
|
"Show the stats overlay (HUD)",
|
||||||
|
s.show_stats,
|
||||||
|
|s, on| s.show_stats = on,
|
||||||
|
)
|
||||||
.tooltip(
|
.tooltip(
|
||||||
"The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \
|
"The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \
|
||||||
Ctrl+Alt+Shift+S toggles it live while streaming.",
|
Ctrl+Alt+Shift+S toggles it live while streaming.",
|
||||||
@@ -356,7 +359,6 @@ pub(crate) fn settings_page(
|
|||||||
bitrate_box.into(),
|
bitrate_box.into(),
|
||||||
hdr_toggle.into(),
|
hdr_toggle.into(),
|
||||||
hud_toggle.into(),
|
hud_toggle.into(),
|
||||||
engine_combo.into(),
|
|
||||||
]);
|
]);
|
||||||
controls
|
controls
|
||||||
}),
|
}),
|
||||||
@@ -376,7 +378,12 @@ pub(crate) fn settings_page(
|
|||||||
"about" => ("About", settings_card(vec![licenses_button.into()])),
|
"about" => ("About", settings_card(vec![licenses_button.into()])),
|
||||||
_ => (
|
_ => (
|
||||||
"Display",
|
"Display",
|
||||||
settings_card(vec![res_combo.into(), hz_combo.into(), comp_combo.into()]),
|
settings_card(vec![
|
||||||
|
res_combo.into(),
|
||||||
|
hz_combo.into(),
|
||||||
|
fullscreen_toggle.into(),
|
||||||
|
comp_combo.into(),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
|||||||
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
||||||
cx.use_effect_with_cleanup((), {
|
cx.use_effect_with_cleanup((), {
|
||||||
let shared = ctx.shared.clone();
|
let shared = ctx.shared.clone();
|
||||||
let (inhibit, show_hud) = {
|
let (inhibit, show_stats) = {
|
||||||
let s = ctx.settings.lock().unwrap();
|
let s = ctx.settings.lock().unwrap();
|
||||||
(s.inhibit_shortcuts, s.show_hud)
|
(s.inhibit_shortcuts, s.show_stats)
|
||||||
};
|
};
|
||||||
let connector_ref = connector_ref.clone();
|
let connector_ref = connector_ref.clone();
|
||||||
move || {
|
move || {
|
||||||
@@ -91,7 +91,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
|||||||
let clock_offset = connector.clock_offset_ns;
|
let clock_offset = connector.clock_offset_ns;
|
||||||
connector_ref.set(Some(connector.clone()));
|
connector_ref.set(Some(connector.clone()));
|
||||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
||||||
crate::input::install(connector, mode, inhibit, show_hud, stop);
|
crate::input::install(connector, mode, inhibit, show_stats, stop);
|
||||||
}
|
}
|
||||||
Some(|| {
|
Some(|| {
|
||||||
RENDER.with(|c| {
|
RENDER.with(|c| {
|
||||||
@@ -166,10 +166,12 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
|||||||
pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element {
|
pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element {
|
||||||
use super::style::{avatar, card, pill, Pill};
|
use super::style::{avatar, card, pill, Pill};
|
||||||
let host = ctx.shared.target.lock().unwrap().name.clone();
|
let host = ctx.shared.target.lock().unwrap().name.clone();
|
||||||
let title = if host.is_empty() {
|
let browse = ctx.shared.browse.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
"Streaming".to_string()
|
let title = match (browse, host.is_empty()) {
|
||||||
} else {
|
(true, true) => "Console library".to_string(),
|
||||||
format!("Streaming to {host}")
|
(true, false) => format!("Console library \u{00B7} {host}"),
|
||||||
|
(false, true) => "Streaming".to_string(),
|
||||||
|
(false, false) => format!("Streaming to {host}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Header: monogram + title + the one thing worth knowing (where the video went).
|
// Header: monogram + title + the one thing worth knowing (where the video went).
|
||||||
@@ -179,9 +181,11 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
|
|||||||
.vertical_alignment(VerticalAlignment::Center),
|
.vertical_alignment(VerticalAlignment::Center),
|
||||||
vstack((
|
vstack((
|
||||||
text_block(&title).font_size(18.0).semibold(),
|
text_block(&title).font_size(18.0).semibold(),
|
||||||
text_block("The stream runs in its own window \u{2014} click it to capture input.")
|
text_block(
|
||||||
.font_size(12.0)
|
"The stream has its own window \u{2014} this one returns when the session ends.",
|
||||||
.foreground(ThemeRef::SecondaryText),
|
)
|
||||||
|
.font_size(12.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText),
|
||||||
))
|
))
|
||||||
.spacing(2.0)
|
.spacing(2.0)
|
||||||
.grid_column(1)
|
.grid_column(1)
|
||||||
@@ -196,12 +200,22 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
|
|||||||
// a chip row (the decode path gets the status colour), the rest dim stage lines.
|
// a chip row (the decode path gets the status colour), the rest dim stage lines.
|
||||||
let mut body: Vec<Element> = vec![header];
|
let mut body: Vec<Element> = vec![header];
|
||||||
if hud.stats_line.is_empty() {
|
if hud.stats_line.is_empty() {
|
||||||
body.push(
|
// The child prints `stats:` lines only while its stats view is on (the Settings
|
||||||
text_block("Waiting for the first stats window\u{2026}")
|
// toggle / Ctrl+Alt+Shift+S) — say so instead of waiting forever. Browse idles in
|
||||||
.font_size(11.0)
|
// the library between launches, so no stats there is simply normal: no line.
|
||||||
.foreground(ThemeRef::SecondaryText)
|
if !browse {
|
||||||
.into(),
|
let msg = if ctx.settings.lock().unwrap().show_stats {
|
||||||
);
|
"Waiting for the first stats window\u{2026}"
|
||||||
|
} else {
|
||||||
|
"Stats are off \u{2014} Ctrl+Alt+Shift+S in the stream window turns them on."
|
||||||
|
};
|
||||||
|
body.push(
|
||||||
|
text_block(msg)
|
||||||
|
.font_size(11.0)
|
||||||
|
.foreground(ThemeRef::SecondaryText)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let mut segments = hud.stats_line.split(" | ");
|
let mut segments = hud.stats_line.split(" | ");
|
||||||
if let Some(first) = segments.next() {
|
if let Some(first) = segments.next() {
|
||||||
@@ -236,6 +250,7 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
|
|||||||
Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen",
|
Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen",
|
||||||
)
|
)
|
||||||
.font_size(11.0)
|
.font_size(11.0)
|
||||||
|
.wrap()
|
||||||
.foreground(ThemeRef::SecondaryText)
|
.foreground(ThemeRef::SecondaryText)
|
||||||
.margin(edges(0.0, 4.0, 0.0, 0.0))
|
.margin(edges(0.0, 4.0, 0.0, 0.0))
|
||||||
.into(),
|
.into(),
|
||||||
@@ -253,9 +268,12 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
|
|||||||
.into()
|
.into()
|
||||||
});
|
});
|
||||||
|
|
||||||
// One centred card, sized like the app's dialogs.
|
// One centred card, sized like the app's dialogs — Stretch + max_width (the `page`
|
||||||
border(card(vstack(body).spacing(12.0).width(520.0)))
|
// pattern) instead of a fixed width, so a narrow window shrinks the card instead of
|
||||||
.horizontal_alignment(HorizontalAlignment::Center)
|
// clipping it while a wide one still centres it at 520.
|
||||||
|
border(card(vstack(body).spacing(12.0)))
|
||||||
|
.max_width(520.0)
|
||||||
|
.margin(uniform(24.0))
|
||||||
.vertical_alignment(VerticalAlignment::Center)
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,16 +117,22 @@ pub(crate) fn busy_page(headline: &str, detail: &str, extra: Vec<Element>) -> El
|
|||||||
text_block(headline)
|
text_block(headline)
|
||||||
.font_size(18.0)
|
.font_size(18.0)
|
||||||
.semibold()
|
.semibold()
|
||||||
|
.wrap()
|
||||||
.horizontal_alignment(HorizontalAlignment::Center)
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
.into(),
|
.into(),
|
||||||
text_block(detail)
|
text_block(detail)
|
||||||
|
.wrap()
|
||||||
.foreground(ThemeRef::SecondaryText)
|
.foreground(ThemeRef::SecondaryText)
|
||||||
.horizontal_alignment(HorizontalAlignment::Center)
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
.into(),
|
.into(),
|
||||||
];
|
];
|
||||||
children.extend(extra);
|
children.extend(extra);
|
||||||
|
// max_width + side margins so the text column reads well wide AND wraps instead of
|
||||||
|
// clipping narrow.
|
||||||
vstack(children)
|
vstack(children)
|
||||||
.spacing(16.0)
|
.spacing(16.0)
|
||||||
|
.max_width(520.0)
|
||||||
|
.margin(edges(24.0, 0.0, 24.0, 0.0))
|
||||||
.horizontal_alignment(HorizontalAlignment::Center)
|
.horizontal_alignment(HorizontalAlignment::Center)
|
||||||
.vertical_alignment(VerticalAlignment::Center)
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
.into()
|
.into()
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ const G: f32 = 9.80665;
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PadInfo {
|
pub struct PadInfo {
|
||||||
/// SDL joystick instance id — the settings GUI's pin key.
|
/// Stable identity (`vid:pid:name`, the same format as `pf-client-core`'s `PadInfo::key`)
|
||||||
pub id: u32,
|
/// — persisted as `Settings::forward_pad` so the pin survives restarts AND reaches the
|
||||||
|
/// spawned session binary, whose own gamepad service applies the same key.
|
||||||
|
pub key: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
|
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
|
||||||
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
|
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
|
||||||
@@ -74,14 +76,13 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
|||||||
enum Ctl {
|
enum Ctl {
|
||||||
Attach(Arc<NativeClient>),
|
Attach(Arc<NativeClient>),
|
||||||
Detach,
|
Detach,
|
||||||
Pin(Option<u32>),
|
Pin(Option<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct GamepadService {
|
pub struct GamepadService {
|
||||||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||||
active: Arc<Mutex<Option<PadInfo>>>,
|
active: Arc<Mutex<Option<PadInfo>>>,
|
||||||
pinned: Arc<Mutex<Option<u32>>>,
|
|
||||||
// `Arc<Mutex<…>>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the
|
// `Arc<Mutex<…>>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the
|
||||||
// WinUI app shares it across the UI thread and the session-pump thread (attach/detach).
|
// WinUI app shares it across the UI thread and the session-pump thread (attach/detach).
|
||||||
ctl: Arc<Mutex<Sender<Ctl>>>,
|
ctl: Arc<Mutex<Sender<Ctl>>>,
|
||||||
@@ -91,13 +92,12 @@ impl GamepadService {
|
|||||||
pub fn start() -> GamepadService {
|
pub fn start() -> GamepadService {
|
||||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||||
let active = Arc::new(Mutex::new(None));
|
let active = Arc::new(Mutex::new(None));
|
||||||
let pinned = Arc::new(Mutex::new(None));
|
|
||||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
let (p, a) = (pads.clone(), active.clone());
|
||||||
if let Err(e) = std::thread::Builder::new()
|
if let Err(e) = std::thread::Builder::new()
|
||||||
.name("punktfunk-gamepad".into())
|
.name("punktfunk-gamepad".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx) {
|
if let Err(e) = run(&p, &a, &ctl_rx) {
|
||||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -107,7 +107,6 @@ impl GamepadService {
|
|||||||
GamepadService {
|
GamepadService {
|
||||||
pads,
|
pads,
|
||||||
active,
|
active,
|
||||||
pinned,
|
|
||||||
ctl: Arc::new(Mutex::new(ctl)),
|
ctl: Arc::new(Mutex::new(ctl)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,13 +120,11 @@ impl GamepadService {
|
|||||||
self.active.lock().unwrap().clone()
|
self.active.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user-pinned controller (settings GUI), if any — else auto (most recent).
|
/// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
|
||||||
pub fn pinned(&self) -> Option<u32> {
|
/// The pin survives the pad disconnecting: it re-applies the moment a matching
|
||||||
*self.pinned.lock().unwrap()
|
/// controller shows up again (same semantics as `pf-client-core`'s service).
|
||||||
}
|
pub fn set_pinned(&self, key: Option<String>) {
|
||||||
|
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(key));
|
||||||
pub fn set_pinned(&self, id: Option<u32>) {
|
|
||||||
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||||
@@ -251,7 +248,9 @@ struct Worker {
|
|||||||
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||||||
/// Connection order; the most recently connected is the auto selection.
|
/// Connection order; the most recently connected is the auto selection.
|
||||||
order: Vec<u32>,
|
order: Vec<u32>,
|
||||||
pinned: Option<u32>,
|
/// The user pin by stable key (`PadInfo::key`); resolved to an instance id per lookup
|
||||||
|
/// so it re-applies whenever a matching pad (re)connects.
|
||||||
|
pinned: Option<String>,
|
||||||
attached: Option<Arc<NativeClient>>,
|
attached: Option<Arc<NativeClient>>,
|
||||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||||
last_axis: [i32; 6],
|
last_axis: [i32; 6],
|
||||||
@@ -265,7 +264,14 @@ struct Worker {
|
|||||||
impl Worker {
|
impl Worker {
|
||||||
fn active_id(&self) -> Option<u32> {
|
fn active_id(&self) -> Option<u32> {
|
||||||
self.pinned
|
self.pinned
|
||||||
.filter(|id| self.opened.contains_key(id))
|
.as_deref()
|
||||||
|
.and_then(|key| {
|
||||||
|
self.order
|
||||||
|
.iter()
|
||||||
|
.rev() // prefer the most recently connected pad with this identity
|
||||||
|
.find(|&&id| self.pad_info(id).is_some_and(|p| p.key == key))
|
||||||
|
.copied()
|
||||||
|
})
|
||||||
.or_else(|| self.order.last().copied())
|
.or_else(|| self.order.last().copied())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,16 +281,18 @@ impl Worker {
|
|||||||
self.subsystem
|
self.subsystem
|
||||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||||
);
|
);
|
||||||
|
let (vid, pid) = (pad.vendor_id().unwrap_or(0), pad.product_id().unwrap_or(0));
|
||||||
// No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205,
|
// No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205,
|
||||||
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
|
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
|
||||||
if pad.vendor_id() == Some(0x28DE)
|
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
|
||||||
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
|
||||||
{
|
|
||||||
pref = GamepadPref::SteamDeck;
|
pref = GamepadPref::SteamDeck;
|
||||||
}
|
}
|
||||||
|
let name = pad.name().unwrap_or_else(|| "Controller".into());
|
||||||
Some(PadInfo {
|
Some(PadInfo {
|
||||||
id,
|
// Must match pf-client-core's `PadInfo::key` byte-for-byte — the persisted
|
||||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
// `forward_pad` is applied by BOTH services (this one and the session's).
|
||||||
|
key: format!("{vid:04x}:{pid:04x}:{name}"),
|
||||||
|
name,
|
||||||
pref,
|
pref,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -399,7 +407,6 @@ impl Worker {
|
|||||||
fn run(
|
fn run(
|
||||||
pads_out: &Mutex<Vec<PadInfo>>,
|
pads_out: &Mutex<Vec<PadInfo>>,
|
||||||
active_out: &Mutex<Option<PadInfo>>,
|
active_out: &Mutex<Option<PadInfo>>,
|
||||||
pinned_out: &Mutex<Option<u32>>,
|
|
||||||
ctl: &Receiver<Ctl>,
|
ctl: &Receiver<Ctl>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own
|
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own
|
||||||
@@ -431,7 +438,6 @@ fn run(
|
|||||||
list.reverse(); // most recent first — the Settings list order
|
list.reverse(); // most recent first — the Settings list order
|
||||||
*pads_out.lock().unwrap() = list;
|
*pads_out.lock().unwrap() = list;
|
||||||
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
|
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
|
||||||
*pinned_out.lock().unwrap() = w.pinned;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -448,9 +454,9 @@ fn run(
|
|||||||
w.set_sensors(false);
|
w.set_sensors(false);
|
||||||
w.attached = None;
|
w.attached = None;
|
||||||
}
|
}
|
||||||
Ok(Ctl::Pin(id)) => {
|
Ok(Ctl::Pin(key)) => {
|
||||||
let before = w.active_id();
|
let before = w.active_id();
|
||||||
w.pinned = id;
|
w.pinned = key;
|
||||||
if w.active_id() != before {
|
if w.active_id() != before {
|
||||||
w.flush_held();
|
w.flush_held();
|
||||||
if w.attached.is_some() {
|
if w.attached.is_some() {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ static KBD_HOOK: AtomicIsize = AtomicIsize::new(0);
|
|||||||
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
|
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||||
/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll).
|
/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll).
|
||||||
static CAPTURED: AtomicBool = AtomicBool::new(false);
|
static CAPTURED: AtomicBool = AtomicBool::new(false);
|
||||||
/// Live stats-overlay visibility. Seeded from `Settings::show_hud` at `install`, then toggled by
|
/// Live stats-overlay visibility. Seeded from `Settings::show_stats` at `install`, then toggled by
|
||||||
/// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll
|
/// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll
|
||||||
/// reads it lock-free to drive the overlay.
|
/// reads it lock-free to drive the overlay.
|
||||||
static HUD_VISIBLE: AtomicBool = AtomicBool::new(false);
|
static HUD_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||||
@@ -126,16 +126,16 @@ fn set_captured(st: &mut State, on: bool) {
|
|||||||
|
|
||||||
/// Install the hooks for a streaming session. Call from the UI thread once the window is shown.
|
/// Install the hooks for a streaming session. Call from the UI thread once the window is shown.
|
||||||
/// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local.
|
/// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local.
|
||||||
/// `show_hud` seeds the stats-overlay visibility that Ctrl+Alt+Shift+S then toggles live.
|
/// `show_stats` seeds the stats-overlay visibility that Ctrl+Alt+Shift+S then toggles live.
|
||||||
/// `stop` is the session's stop flag, tripped by the disconnect shortcut.
|
/// `stop` is the session's stop flag, tripped by the disconnect shortcut.
|
||||||
pub fn install(
|
pub fn install(
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
show_hud: bool,
|
show_stats: bool,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
HUD_VISIBLE.store(show_hud, Ordering::Relaxed);
|
HUD_VISIBLE.store(show_stats, Ordering::Relaxed);
|
||||||
let hwnd = unsafe { GetForegroundWindow() };
|
let hwnd = unsafe { GetForegroundWindow() };
|
||||||
let mut st = State {
|
let mut st = State {
|
||||||
connector,
|
connector,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ mod render;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod session;
|
mod session;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
mod shell_window;
|
||||||
|
#[cfg(windows)]
|
||||||
mod spawn;
|
mod spawn;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod trust;
|
mod trust;
|
||||||
@@ -58,6 +60,7 @@ fn main() {
|
|||||||
use windows::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS};
|
use windows::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS};
|
||||||
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
|
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
|
||||||
}
|
}
|
||||||
|
set_app_user_model_id();
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
@@ -100,6 +103,27 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tag unpackaged (dev) runs with the explicit AppUserModelID that `pf-presenter`'s
|
||||||
|
/// session window also adopts, so the shell and the stream window group as ONE taskbar
|
||||||
|
/// app across the shell⇄session visibility handoff. MSIX runs already carry the package
|
||||||
|
/// identity — overriding it would detach the window from the Start-menu pin, so packaged
|
||||||
|
/// processes are left alone. Must run before any window exists.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn set_app_user_model_id() {
|
||||||
|
use windows::Win32::Foundation::APPMODEL_ERROR_NO_PACKAGE;
|
||||||
|
use windows::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
|
||||||
|
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||||
|
unsafe {
|
||||||
|
let mut len: u32 = 0;
|
||||||
|
// No buffer: just probe whether the process has package identity.
|
||||||
|
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
|
||||||
|
return; // packaged (or indeterminate) — leave the identity alone
|
||||||
|
}
|
||||||
|
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
|
||||||
|
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the
|
/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the
|
||||||
/// Windows analogue of `punktfunk-probe`.
|
/// Windows analogue of `punktfunk-probe`.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -192,6 +216,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
|||||||
port,
|
port,
|
||||||
fp_hex: trust::hex(&fp),
|
fp_hex: trust::hex(&fp),
|
||||||
paired: true,
|
paired: true,
|
||||||
|
last_used: None,
|
||||||
mac: Vec::new(),
|
mac: Vec::new(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! Hide/restore the shell's top-level window around a spawned session, so exactly ONE
|
||||||
|
//! Punktfunk window is visible at a time: the spawned stream/browse window IS the app
|
||||||
|
//! while it runs (hidden = no taskbar entry, no Alt-Tab ghost), and the shell reappears
|
||||||
|
//! the moment the child exits — every exit path funnels through the spawn reader's
|
||||||
|
//! `Exited` event (clean end, error, crash, Disconnect kill), so the shell can never stay
|
||||||
|
//! hidden with no child.
|
||||||
|
//!
|
||||||
|
//! windows-reactor exposes no window handle, so the HWND is resolved by its (unique)
|
||||||
|
//! title and cached — the same pattern as `app::apply_window_icon_when_ready` and
|
||||||
|
//! `stream::window_dpi`.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||||
|
use windows::Win32::Foundation::HWND;
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
FindWindowW, IsWindow, SetForegroundWindow, ShowWindow, SW_HIDE, SW_SHOW,
|
||||||
|
};
|
||||||
|
|
||||||
|
static SHELL_HWND: AtomicIsize = AtomicIsize::new(0);
|
||||||
|
|
||||||
|
fn shell_hwnd() -> Option<HWND> {
|
||||||
|
unsafe {
|
||||||
|
let cached = SHELL_HWND.load(Ordering::Relaxed);
|
||||||
|
if cached != 0 {
|
||||||
|
let h = HWND(cached as *mut _);
|
||||||
|
if IsWindow(Some(h)).as_bool() {
|
||||||
|
return Some(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let h = FindWindowW(None, windows::core::w!("Punktfunk")).ok()?;
|
||||||
|
SHELL_HWND.store(h.0 as isize, Ordering::Relaxed);
|
||||||
|
Some(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the shell while a spawned session window is up. Called on the child's
|
||||||
|
/// `{"ready":true}` (its window has presented — never earlier, so a failed connect keeps
|
||||||
|
/// the shell in view with its error banner).
|
||||||
|
pub(crate) fn hide() {
|
||||||
|
if let Some(h) = shell_hwnd() {
|
||||||
|
unsafe {
|
||||||
|
let _ = ShowWindow(h, SW_HIDE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the shell back (and to the foreground) when the child exits. Safe to call when
|
||||||
|
/// it was never hidden — showing a visible window is a no-op.
|
||||||
|
pub(crate) fn restore() {
|
||||||
|
if let Some(h) = shell_hwnd() {
|
||||||
|
unsafe {
|
||||||
|
let _ = ShowWindow(h, SW_SHOW);
|
||||||
|
let _ = SetForegroundWindow(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected`
|
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected`
|
||||||
//! routed to the re-pair PIN ceremony, `stats:` lines to the session status page.
|
//! 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
|
//! The legacy in-process D3D11VA presenter remains reachable via the
|
||||||
//! "Streaming engine" pick or `PUNKTFUNK_BUILTIN_STREAM=1` (`app::use_builtin_stream`) —
|
//! `PUNKTFUNK_BUILTIN_STREAM=1` env override (`app::use_builtin_stream`) — the
|
||||||
//! the A/B baseline until its deletion.
|
//! developer A/B baseline until its deletion.
|
||||||
|
|
||||||
use std::io::BufRead as _;
|
use std::io::BufRead as _;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
@@ -86,34 +86,75 @@ pub(crate) fn session_binary() -> std::path::PathBuf {
|
|||||||
|
|
||||||
/// Spawn the session binary for a connect with `fp_hex` pinned and feed its lifecycle to
|
/// 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
|
/// `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
|
/// can kill it. `fullscreen` starts the stream window fullscreen (the Settings "Start
|
||||||
/// connect error by the caller.
|
/// streams fullscreen" toggle); `launch` carries a library title id for the host to
|
||||||
|
/// launch during the handshake. `Err` = the spawn itself failed (binary missing?) —
|
||||||
|
/// surfaced as a connect error by the caller.
|
||||||
pub(crate) fn spawn_session(
|
pub(crate) fn spawn_session(
|
||||||
addr: &str,
|
addr: &str,
|
||||||
port: u16,
|
port: u16,
|
||||||
fp_hex: &str,
|
fp_hex: &str,
|
||||||
connect_timeout_secs: u64,
|
connect_timeout_secs: u64,
|
||||||
|
fullscreen: bool,
|
||||||
|
launch: Option<&str>,
|
||||||
slot: SessionChild,
|
slot: SessionChild,
|
||||||
mut on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use std::os::windows::process::CommandExt as _;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
||||||
|
|
||||||
let mut cmd = Command::new(session_binary());
|
let mut cmd = Command::new(session_binary());
|
||||||
cmd.arg("--connect")
|
cmd.arg("--connect")
|
||||||
.arg(format!("{addr}:{port}"))
|
.arg(format!("{addr}:{port}"))
|
||||||
.arg("--fp")
|
.arg("--fp")
|
||||||
.arg(fp_hex)
|
.arg(fp_hex)
|
||||||
.arg("--connect-timeout")
|
.arg("--connect-timeout")
|
||||||
.arg(connect_timeout_secs.to_string())
|
.arg(connect_timeout_secs.to_string());
|
||||||
.stdin(Stdio::null())
|
if fullscreen {
|
||||||
|
cmd.arg("--fullscreen");
|
||||||
|
}
|
||||||
|
if let Some(id) = launch {
|
||||||
|
cmd.arg("--launch").arg(id);
|
||||||
|
}
|
||||||
|
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the session binary in `--browse` mode: the console (gamepad) library for a
|
||||||
|
/// PAIRED host, in the session window — launches run as streams in that same window.
|
||||||
|
/// The same stdout contract as a connect (`--json-status`): `ready` when the library
|
||||||
|
/// window presents, `error` on a failed start, EOF on quit.
|
||||||
|
pub(crate) fn spawn_browse(
|
||||||
|
addr: &str,
|
||||||
|
port: u16,
|
||||||
|
fullscreen: bool,
|
||||||
|
slot: SessionChild,
|
||||||
|
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut cmd = Command::new(session_binary());
|
||||||
|
cmd.arg("--browse")
|
||||||
|
.arg(format!("{addr}:{port}"))
|
||||||
|
.arg("--json-status");
|
||||||
|
if fullscreen {
|
||||||
|
cmd.arg("--fullscreen");
|
||||||
|
}
|
||||||
|
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared spawn + stdout-contract reader behind [`spawn_session`]/[`spawn_browse`].
|
||||||
|
fn spawn_with(
|
||||||
|
mut cmd: Command,
|
||||||
|
host_label: &str,
|
||||||
|
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;
|
||||||
|
|
||||||
|
cmd.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
|
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
|
||||||
.creation_flags(CREATE_NO_WINDOW);
|
.creation_flags(CREATE_NO_WINDOW);
|
||||||
let mut child = cmd
|
let mut child = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||||
tracing::info!(host = %addr, port, "session binary spawned");
|
tracing::info!(host = %host_label, "session binary spawned");
|
||||||
|
|
||||||
let stdout = child.stdout.take().expect("piped stdout");
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
// Park the child where the kill handle (and the reader, for the final reap) reach it.
|
// Park the child where the kill handle (and the reader, for the final reap) reach it.
|
||||||
|
|||||||
+12
-254
@@ -1,256 +1,14 @@
|
|||||||
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
|
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings —
|
||||||
|
//! re-exported from `pf-client-core` so the shell and the spawned `punktfunk-session`
|
||||||
|
//! binary share ONE `Settings`/`KnownHosts` shape over the same files
|
||||||
|
//! (`%APPDATA%\punktfunk\client-windows-settings.json` / `client-known-hosts.json`).
|
||||||
//!
|
//!
|
||||||
//! Ported near-verbatim from the GTK Linux client; the only platform change is the config
|
//! The shell is the settings file's only writer; the session only reads it. The shell's
|
||||||
//! directory — `%APPDATA%\punktfunk` (the Windows analogue of `~/.config/punktfunk`), shared
|
//! former private `Settings` copy (≤ 0.8.4: `show_hud`, `engine`) is gone — old files
|
||||||
//! with the Windows host's identity location. The identity files (`client-{cert,key}.pem`)
|
//! still load via a serde alias in core, and the legacy in-process presenter is now
|
||||||
//! keep the same names so the trust model is identical across the native clients.
|
//! reachable only through `PUNKTFUNK_BUILTIN_STREAM=1` (see `app::use_builtin_stream`).
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
pub use pf_client_core::trust::{
|
||||||
use punktfunk_core::quic::endpoint;
|
hex, learn_mac, load_or_create_identity, parse_hex32, touch_last_used, KnownHost, KnownHosts,
|
||||||
use serde::{Deserialize, Serialize};
|
Settings,
|
||||||
use std::path::PathBuf;
|
};
|
||||||
|
|
||||||
pub fn config_dir() -> Result<PathBuf> {
|
|
||||||
let appdata = std::env::var("APPDATA").context("APPDATA unset")?;
|
|
||||||
Ok(PathBuf::from(appdata).join("punktfunk"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This client's persistent identity, generated on first use — presented on every connect
|
|
||||||
/// so hosts can recognize it once paired.
|
|
||||||
pub fn load_or_create_identity() -> Result<(String, String)> {
|
|
||||||
let dir = config_dir()?;
|
|
||||||
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
|
|
||||||
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
|
|
||||||
return Ok((c, k));
|
|
||||||
}
|
|
||||||
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
|
|
||||||
std::fs::create_dir_all(&dir)?;
|
|
||||||
std::fs::write(&cp, &c)?;
|
|
||||||
std::fs::write(&kp, &k)?;
|
|
||||||
tracing::info!(cert = %cp.display(), "generated client identity");
|
|
||||||
Ok((c, k))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn hex(fp: &[u8; 32]) -> String {
|
|
||||||
fp.iter().map(|b| format!("{b:02x}")).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
|
||||||
if s.len() != 64 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut out = [0u8; 32];
|
|
||||||
for (i, b) in out.iter_mut().enumerate() {
|
|
||||||
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One trusted host: its pinned certificate fingerprint plus how we got there (TOFU or a
|
|
||||||
/// PIN ceremony) and where we last reached it.
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub struct KnownHost {
|
|
||||||
pub name: String,
|
|
||||||
pub addr: String,
|
|
||||||
pub port: u16,
|
|
||||||
/// SHA-256 of the host certificate, lowercase hex — the pin for every later connect.
|
|
||||||
pub fp_hex: String,
|
|
||||||
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
|
||||||
pub paired: bool,
|
|
||||||
/// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was
|
|
||||||
/// online, so we can wake it once it sleeps. `default` so pre-existing stores load; empty until
|
|
||||||
/// first learned.
|
|
||||||
#[serde(default)]
|
|
||||||
pub mac: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
|
||||||
pub struct KnownHosts {
|
|
||||||
pub hosts: Vec<KnownHost>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KnownHosts {
|
|
||||||
fn path() -> Result<PathBuf> {
|
|
||||||
Ok(config_dir()?.join("client-known-hosts.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load() -> KnownHosts {
|
|
||||||
Self::path()
|
|
||||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self) -> Result<()> {
|
|
||||||
let p = Self::path()?;
|
|
||||||
std::fs::create_dir_all(p.parent().unwrap())?;
|
|
||||||
std::fs::write(&p, serde_json::to_string_pretty(self)?)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> {
|
|
||||||
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forget a host (the hosts page's "Forget" action): drops the pinned fingerprint, so a
|
|
||||||
/// later connect goes back through pairing/TOFU.
|
|
||||||
pub fn remove_by_fp(&mut self, fp_hex: &str) {
|
|
||||||
self.hosts.retain(|h| h.fp_hex != fp_hex);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
|
|
||||||
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades
|
|
||||||
/// (a later TOFU connect must not demote a PIN-paired host).
|
|
||||||
pub fn upsert(&mut self, entry: KnownHost) {
|
|
||||||
if let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == entry.fp_hex) {
|
|
||||||
h.name = entry.name;
|
|
||||||
h.addr = entry.addr;
|
|
||||||
h.port = entry.port;
|
|
||||||
h.paired |= entry.paired;
|
|
||||||
// A trust-decision upsert (which carries no MAC) must not wipe learned MACs.
|
|
||||||
if !entry.mac.is_empty() {
|
|
||||||
h.mac = entry.mac;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.hosts.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host is
|
|
||||||
/// online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so the
|
|
||||||
/// hosts page can call it on every discovery tick without churning the store.
|
|
||||||
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
|
||||||
if mac.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut known = KnownHosts::load();
|
|
||||||
let Some(h) = known
|
|
||||||
.hosts
|
|
||||||
.iter_mut()
|
|
||||||
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if h.mac == mac {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
h.mac = mac.to_vec();
|
|
||||||
let _ = known.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
|
||||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct Settings {
|
|
||||||
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
|
|
||||||
/// resolved at connect time.
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
pub refresh_hz: u32,
|
|
||||||
/// Requested encoder bitrate (kbps); 0 = host default.
|
|
||||||
pub bitrate_kbps: u32,
|
|
||||||
pub gamepad: String,
|
|
||||||
/// Which host compositor backend to request (advisory; the host falls back to
|
|
||||||
/// auto-detect when unavailable).
|
|
||||||
pub compositor: String,
|
|
||||||
/// Grab system shortcuts (Alt+Tab, Win…) while input is captured.
|
|
||||||
pub inhibit_shortcuts: bool,
|
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
|
||||||
pub mic_enabled: bool,
|
|
||||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
|
||||||
/// can capture; the resolved count drives the decoder + WASAPI render layout.
|
|
||||||
pub audio_channels: u8,
|
|
||||||
/// Advertise 10-bit + HDR10 so the host upgrades HDR content to a Main10/PQ stream (the client
|
|
||||||
/// presents it on a 10-bit ST.2084 swapchain). No effect on SDR content.
|
|
||||||
pub hdr_enabled: bool,
|
|
||||||
/// Video decode backend: `auto` (D3D11VA, fall back to software), `hardware`, or `software`.
|
|
||||||
pub decoder: String,
|
|
||||||
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
|
|
||||||
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
|
|
||||||
#[serde(default = "default_codec")]
|
|
||||||
pub codec: String,
|
|
||||||
/// Decode/present GPU: the DXGI adapter description to prefer on a multi-GPU box; empty =
|
|
||||||
/// automatic (the adapter driving the window's monitor). Applies from the next session; a
|
|
||||||
/// vanished adapter (eGPU unplugged) falls back to automatic.
|
|
||||||
#[serde(default)]
|
|
||||||
pub adapter: String,
|
|
||||||
/// 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 {
|
|
||||||
"auto".into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Settings {
|
|
||||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
|
||||||
pub fn preferred_codec(&self) -> u8 {
|
|
||||||
match self.codec.as_str() {
|
|
||||||
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
|
|
||||||
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
|
|
||||||
"av1" => punktfunk_core::quic::CODEC_AV1,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Settings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Settings {
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
refresh_hz: 0,
|
|
||||||
bitrate_kbps: 0,
|
|
||||||
gamepad: "auto".into(),
|
|
||||||
compositor: "auto".into(),
|
|
||||||
inhibit_shortcuts: true,
|
|
||||||
mic_enabled: false,
|
|
||||||
audio_channels: 2,
|
|
||||||
hdr_enabled: true,
|
|
||||||
decoder: "auto".into(),
|
|
||||||
codec: "auto".into(),
|
|
||||||
adapter: String::new(),
|
|
||||||
show_hud: true,
|
|
||||||
engine: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Settings {
|
|
||||||
fn path() -> Result<PathBuf> {
|
|
||||||
Ok(config_dir()?.join("client-windows-settings.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load() -> Settings {
|
|
||||||
Self::path()
|
|
||||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self) {
|
|
||||||
let Ok(p) = Self::path() else { return };
|
|
||||||
let _ = std::fs::create_dir_all(p.parent().unwrap());
|
|
||||||
if let Ok(s) = serde_json::to_string_pretty(self) {
|
|
||||||
let _ = std::fs::write(&p, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` (Linux; on Windows
|
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` (Linux; on Windows
|
||||||
//! `%APPDATA%\punktfunk`, the WinUI shell's directory) with `punktfunk-probe` so a box
|
//! `%APPDATA%\punktfunk`, the WinUI shell's directory) with `punktfunk-probe` so a box
|
||||||
//! pairs once whichever client it uses. On Windows the session binary reads the SAME
|
//! pairs once whichever client it uses. On Windows the session binary reads the SAME
|
||||||
//! stores the WinUI shell (`clients/windows/src/trust.rs`) writes — pairing there makes
|
//! stores the WinUI shell writes — pairing there makes the session connect silently,
|
||||||
//! the session connect silently, mirroring the GTK-shell arrangement on Linux. The two
|
//! mirroring the GTK-shell arrangement on Linux. The WinUI shell re-exports THIS module
|
||||||
//! `Settings` structs differ in shape; `#[serde(default)]` on both sides reconciles them
|
//! (`clients/windows/src/trust.rs`), so both processes share one `Settings` shape; the
|
||||||
//! (see the parity tests below), and the shell stays the settings file's only writer.
|
//! shell stays the settings file's only writer (the session only reads). Pre-unification
|
||||||
|
//! shell files (≤ 0.8.4: `show_hud`, `engine`) still load — see the migration test below.
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
@@ -282,6 +283,8 @@ pub struct Settings {
|
|||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub hdr_enabled: bool,
|
pub hdr_enabled: bool,
|
||||||
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
||||||
|
/// `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted this as `show_hud`.
|
||||||
|
#[serde(alias = "show_hud")]
|
||||||
pub show_stats: bool,
|
pub show_stats: bool,
|
||||||
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
||||||
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
||||||
@@ -338,9 +341,8 @@ impl Default for Settings {
|
|||||||
impl Settings {
|
impl Settings {
|
||||||
fn path() -> Result<PathBuf> {
|
fn path() -> Result<PathBuf> {
|
||||||
// The shell's settings file on each OS: the GTK shell's on Linux, the WinUI
|
// The shell's settings file on each OS: the GTK shell's on Linux, the WinUI
|
||||||
// shell's on Windows. The shells own (and write) these files; the session binary
|
// shell's on Windows. The shells own (and write) these files through this one
|
||||||
// only reads them, so `save` must never be called on Windows — it would rewrite
|
// struct; the session binary only reads them and must never call `save`.
|
||||||
// the file in THIS struct's shape and drop the WinUI-only fields.
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
return Ok(config_dir()?.join("client-windows-settings.json"));
|
return Ok(config_dir()?.join("client-windows-settings.json"));
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
@@ -379,12 +381,11 @@ mod tests {
|
|||||||
assert_eq!(round.forward_pad, "");
|
assert_eq!(round.forward_pad, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On Windows the session reads the WinUI shell's settings file. This fixture is the
|
/// A pre-unification WinUI shell settings file (≤ 0.8.4, when the shell had its own
|
||||||
/// shell's `Settings` shape (clients/windows/src/trust.rs) verbatim — if that struct
|
/// `Settings` struct) still loads: `show_hud` migrates onto `show_stats` via the serde
|
||||||
/// changes, update this fixture with it. WinUI-only fields (hdr_enabled, adapter,
|
/// alias, the dropped `engine` knob is ignored, fields that file never carried
|
||||||
/// show_hud) must be ignored; fields this struct has and the shell's lacks
|
/// (forward_pad, fullscreen_on_stream, …) default, and the D3D11VA-era
|
||||||
/// (forward_pad, show_stats, …) must default; the shell's D3D11VA-era
|
/// `decoder: "hardware"` survives as-is (video::Decoder::new reads it as auto).
|
||||||
/// `decoder: "hardware"` must survive as-is (video::Decoder::new reads it as auto).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn settings_reads_winui_shell_shape() {
|
fn settings_reads_winui_shell_shape() {
|
||||||
let shell = r#"{
|
let shell = r#"{
|
||||||
@@ -392,7 +393,7 @@ mod tests {
|
|||||||
"gamepad": "dualsense", "compositor": "auto",
|
"gamepad": "dualsense", "compositor": "auto",
|
||||||
"inhibit_shortcuts": true, "mic_enabled": true, "audio_channels": 6,
|
"inhibit_shortcuts": true, "mic_enabled": true, "audio_channels": 6,
|
||||||
"hdr_enabled": true, "decoder": "hardware", "codec": "av1",
|
"hdr_enabled": true, "decoder": "hardware", "codec": "av1",
|
||||||
"adapter": "NVIDIA GeForce RTX 4080", "show_hud": false
|
"adapter": "NVIDIA GeForce RTX 4080", "show_hud": false, "engine": "builtin"
|
||||||
}"#;
|
}"#;
|
||||||
let s: Settings = serde_json::from_str(shell).unwrap();
|
let s: Settings = serde_json::from_str(shell).unwrap();
|
||||||
assert_eq!((s.width, s.height, s.refresh_hz), (2560, 1440, 120));
|
assert_eq!((s.width, s.height, s.refresh_hz), (2560, 1440, 120));
|
||||||
@@ -403,9 +404,10 @@ mod tests {
|
|||||||
assert_eq!(s.preferred_codec(), punktfunk_core::quic::CODEC_AV1);
|
assert_eq!(s.preferred_codec(), punktfunk_core::quic::CODEC_AV1);
|
||||||
assert_eq!(s.adapter, "NVIDIA GeForce RTX 4080");
|
assert_eq!(s.adapter, "NVIDIA GeForce RTX 4080");
|
||||||
assert!(s.hdr_enabled);
|
assert!(s.hdr_enabled);
|
||||||
// Fields the shell's file doesn't carry take this struct's defaults.
|
// The old shell's `show_hud` lands on `show_stats` (the user's preference survives).
|
||||||
|
assert!(!s.show_stats);
|
||||||
|
// Fields the old file doesn't carry take this struct's defaults.
|
||||||
assert_eq!(s.forward_pad, "");
|
assert_eq!(s.forward_pad, "");
|
||||||
assert!(s.show_stats);
|
|
||||||
assert!(s.fullscreen_on_stream);
|
assert!(s.fullscreen_on_stream);
|
||||||
assert!(!s.library_enabled);
|
assert!(!s.library_enabled);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,3 +33,13 @@ sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
|
|||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] }
|
sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] }
|
||||||
|
# Window branding only (win32.rs): the exe-embedded icon onto the SDL window's title
|
||||||
|
# bar/taskbar (WM_SETICON) and the shared AppUserModelID for unpackaged runs, so the
|
||||||
|
# shell and the session window group as ONE taskbar app across the visibility handoff.
|
||||||
|
windows-sys = { version = "0.61", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Storage_Packaging_Appx",
|
||||||
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_UI_Shell",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ pub mod overlay;
|
|||||||
mod run;
|
mod run;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
pub mod vk;
|
pub mod vk;
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod win32;
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
pub use run::{run_browse, run_session, ActionOutcome, Outcome, SessionOpts};
|
pub use run::{run_browse, run_session, ActionOutcome, Outcome, SessionOpts};
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ impl StreamState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>> {
|
fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>> {
|
||||||
|
// Before any window exists: unpackaged runs adopt the shell's AppUserModelID so the
|
||||||
|
// shell⇄session windows group as one taskbar app (win32.rs; MSIX identity wins).
|
||||||
|
#[cfg(windows)]
|
||||||
|
crate::win32::set_app_user_model_id();
|
||||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||||
let sdl = sdl3::init().context("SDL init")?;
|
let sdl = sdl3::init().context("SDL init")?;
|
||||||
let video = sdl.video().context("SDL video")?;
|
let video = sdl.video().context("SDL video")?;
|
||||||
@@ -241,12 +245,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
}
|
}
|
||||||
b.build().context("SDL window")?
|
b.build().context("SDL window")?
|
||||||
};
|
};
|
||||||
|
// The exe-embedded icon onto the title bar/taskbar/Alt-Tab (SDL's class icon is the
|
||||||
|
// generic default); a no-op for exes that embed none.
|
||||||
|
#[cfg(windows)]
|
||||||
|
crate::win32::stamp_window_icon(&window);
|
||||||
let instance_exts = window
|
let instance_exts = window
|
||||||
.vulkan_instance_extensions()
|
.vulkan_instance_extensions()
|
||||||
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
|
||||||
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
|
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
|
||||||
// A valid black frame immediately — the window is honest while the connect runs.
|
// A valid black frame immediately — the window is honest while the connect runs.
|
||||||
presenter.present(&window, FrameInput::Redraw, None)?;
|
presenter.present(&window, FrameInput::Redraw, None)?;
|
||||||
|
// Browse mode is "ready" the moment the library window presents — there may never be
|
||||||
|
// a stream. (Single mode announces on the first VIDEO frame instead, further down, so
|
||||||
|
// a shell only yields to a window that actually shows the stream.)
|
||||||
|
if opts.json_status && matches!(mode, ModeCtl::Browse(_)) {
|
||||||
|
println!("{{\"ready\":true}}");
|
||||||
|
}
|
||||||
|
|
||||||
let mut overlay = opts.overlay.take();
|
let mut overlay = opts.overlay.take();
|
||||||
if let Some(o) = overlay.as_mut() {
|
if let Some(o) = overlay.as_mut() {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! Windows-only window branding for the SDL session window.
|
||||||
|
//!
|
||||||
|
//! SDL registers its window class with a generic icon, so without help the taskbar and
|
||||||
|
//! Alt-Tab show the SDL default even when the exe embeds ours. [`stamp_window_icon`]
|
||||||
|
//! stamps the exe's icon resource (ordinal 1, when embedded — see the session binary's
|
||||||
|
//! `build.rs`) onto the window via `WM_SETICON`, at the native small/big metrics so
|
||||||
|
//! nothing is scaled at draw time — the same treatment the WinUI shell gives its window.
|
||||||
|
//!
|
||||||
|
//! [`set_app_user_model_id`] gives unpackaged (dev/CLI) runs the same explicit
|
||||||
|
//! AppUserModelID as the shell, so the two windows group as ONE taskbar app across the
|
||||||
|
//! shell⇄session visibility handoff. MSIX runs already share the package identity, and
|
||||||
|
//! overriding it would detach the window from the Start-menu pin — so packaged processes
|
||||||
|
//! are left alone.
|
||||||
|
|
||||||
|
use windows_sys::core::w;
|
||||||
|
use windows_sys::Win32::Foundation::{APPMODEL_ERROR_NO_PACKAGE, HWND, LPARAM, WPARAM};
|
||||||
|
use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
|
||||||
|
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
|
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
|
GetSystemMetrics, LoadImageW, SendMessageW, ICON_BIG, ICON_SMALL, IMAGE_ICON, LR_DEFAULTCOLOR,
|
||||||
|
SM_CXICON, SM_CXSMICON, WM_SETICON,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The shared taskbar identity — must stay in sync with the WinUI shell's
|
||||||
|
/// (`clients/windows/src/main.rs`), or the two windows stop grouping.
|
||||||
|
const APP_USER_MODEL_ID: *const u16 = w!("unom.punktfunk.client");
|
||||||
|
|
||||||
|
/// Tag this process with the shared AppUserModelID — unpackaged runs only (a packaged
|
||||||
|
/// process already carries the MSIX identity, which must win). Call before any window
|
||||||
|
/// exists; later calls don't re-tag existing windows.
|
||||||
|
pub(crate) fn set_app_user_model_id() {
|
||||||
|
unsafe {
|
||||||
|
let mut len: u32 = 0;
|
||||||
|
// No buffer: just probe whether the process has package identity.
|
||||||
|
if GetCurrentPackageFullName(&mut len, std::ptr::null_mut()) != APPMODEL_ERROR_NO_PACKAGE {
|
||||||
|
return; // packaged (or indeterminate) — leave the identity alone
|
||||||
|
}
|
||||||
|
let _ = SetCurrentProcessExplicitAppUserModelID(APP_USER_MODEL_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp the exe-embedded icon (resource ordinal 1) onto the SDL window's title bar,
|
||||||
|
/// taskbar and Alt-Tab. A quiet no-op when the exe embeds no icon.
|
||||||
|
pub(crate) fn stamp_window_icon(window: &sdl3::video::Window) {
|
||||||
|
unsafe {
|
||||||
|
// The HWND behind the SDL window, via SDL3's window properties (the same route
|
||||||
|
// the sdl3 crate's raw-window-handle integration takes).
|
||||||
|
let hwnd: HWND = sdl3::sys::properties::SDL_GetPointerProperty(
|
||||||
|
sdl3::sys::video::SDL_GetWindowProperties(window.raw()),
|
||||||
|
sdl3::sys::video::SDL_PROP_WINDOW_WIN32_HWND_POINTER,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
) as HWND;
|
||||||
|
if hwnd.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let module = GetModuleHandleW(std::ptr::null());
|
||||||
|
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
|
||||||
|
let px = GetSystemMetrics(metric);
|
||||||
|
let icon = LoadImageW(module, 1 as *const u16, IMAGE_ICON, px, px, LR_DEFAULTCOLOR);
|
||||||
|
if !icon.is_null() {
|
||||||
|
SendMessageW(hwnd, WM_SETICON, which as WPARAM, icon as LPARAM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user