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:
@@ -20,6 +20,10 @@ path = "src/main.rs"
|
||||
# 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.
|
||||
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
|
||||
# `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_Graphics_Dxgi",
|
||||
"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_Direct3D11",
|
||||
"Win32_Graphics_Direct3D_Fxc",
|
||||
|
||||
@@ -50,6 +50,9 @@ fn initiate_opts(
|
||||
set_status: &AsyncSetState<String>,
|
||||
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 pin = target
|
||||
.fp_hex
|
||||
@@ -292,10 +295,12 @@ fn connect_with(
|
||||
port: target.port,
|
||||
fp_hex: trust::hex(&fingerprint),
|
||||
paired: persist_paired,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
gamepad.attach(connector.clone());
|
||||
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
|
||||
*shared.handoff.lock().unwrap() =
|
||||
@@ -367,6 +372,8 @@ fn connect_spawn(
|
||||
let child = crate::spawn::SessionChild::default();
|
||||
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||
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_screen.call(if opts.awaiting_approval {
|
||||
Screen::RequestAccess
|
||||
@@ -388,9 +395,16 @@ fn connect_spawn(
|
||||
port,
|
||||
&fp_arg,
|
||||
opts.connect_timeout.as_secs(),
|
||||
fullscreen,
|
||||
None,
|
||||
child,
|
||||
move |event| {
|
||||
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 —
|
||||
// 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)) {
|
||||
@@ -410,10 +424,15 @@ fn connect_spawn(
|
||||
port: target.port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
paired: persist_paired,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
});
|
||||
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);
|
||||
}
|
||||
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
|
||||
/// 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
|
||||
@@ -553,6 +619,7 @@ fn wake_and_connect(
|
||||
port: target.port,
|
||||
fp_hex: fp,
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
});
|
||||
let _ = k.save();
|
||||
|
||||
@@ -39,6 +39,7 @@ fn shortcuts_reference() -> Element {
|
||||
let row = i as i32;
|
||||
children.push(key_chip(keys).grid_row(row).grid_column(0));
|
||||
let action_cell: Element = text_block(*action)
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! 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.
|
||||
|
||||
use super::connect::{initiate, initiate_waking};
|
||||
use super::connect::{initiate, initiate_waking, open_console};
|
||||
use super::speed::SpeedState;
|
||||
use super::style::*;
|
||||
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.
|
||||
const MENU_CONNECT: &str = "Connect";
|
||||
const MENU_CONSOLE: &str = "Open console UI";
|
||||
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
||||
const MENU_WAKE: &str = "Wake host";
|
||||
const MENU_RENAME: &str = "Rename\u{2026}";
|
||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||
|
||||
/// 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.
|
||||
const TILE_MIN_WIDTH: f64 = 320.0;
|
||||
const TILE_GAP: f64 = 12.0;
|
||||
@@ -37,6 +43,9 @@ pub(crate) struct HostsProps {
|
||||
pub(crate) svc: Svc,
|
||||
pub(crate) hosts: Vec<DiscoveredHost>,
|
||||
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) rename: Option<(String, String)>,
|
||||
/// 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.hosts == other.hosts
|
||||
&& self.status == other.status
|
||||
&& self.pads == other.pads
|
||||
&& self.forget == other.forget
|
||||
&& self.rename == other.rename
|
||||
&& self.show_add == other.show_add
|
||||
@@ -93,6 +103,7 @@ fn host_tile(
|
||||
text_block(name)
|
||||
.font_size(15.0)
|
||||
.semibold()
|
||||
.wrap()
|
||||
.margin(edges(0.0, 12.0, 0.0, 0.0)),
|
||||
text_block(sub)
|
||||
.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 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);
|
||||
// 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();
|
||||
|
||||
// Header: title block + Settings button.
|
||||
// Header: title block + Add host / Help / Settings.
|
||||
body.push(
|
||||
grid((
|
||||
vstack((
|
||||
text_block("Punktfunk").font_size(30.0).bold(),
|
||||
text_block("Stream from a host on your network.")
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(2.0)
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
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();
|
||||
move || sa.call(true)
|
||||
}),
|
||||
button("Help").icon(Symbol::Help).on_click({
|
||||
header_btn("Help", Symbol::Help).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Help)
|
||||
}),
|
||||
button("Settings").icon(Symbol::Setting).on_click({
|
||||
header_btn("Settings", Symbol::Setting).on_click({
|
||||
let ss = set_screen.clone();
|
||||
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
|
||||
// being advertised right now shows as Online (and is deduped out of the discovery section).
|
||||
if !known.hosts.is_empty() {
|
||||
@@ -347,7 +426,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.tooltip("More options")
|
||||
.automation_name("More options")
|
||||
.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.
|
||||
if can_wake {
|
||||
items.push(menu_item(MENU_WAKE));
|
||||
@@ -361,6 +446,9 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
MENU_CONNECT => {
|
||||
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_SPEED => {
|
||||
*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("Licensed under MIT OR Apache-2.0, at your option.")
|
||||
.font_size(12.0)
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
text_block(APP_LICENSE)
|
||||
.font_size(11.0)
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(8.0),
|
||||
@@ -43,6 +45,7 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
Windows App SDK (Microsoft) are also linked.",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.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(THIRD_PARTY_NOTICES)
|
||||
.font_size(11.0)
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.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
|
||||
/// mid-run can't overwrite a newer run's result when it finally resolves.
|
||||
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 {
|
||||
@@ -143,12 +146,11 @@ pub struct AppCtx {
|
||||
}
|
||||
|
||||
/// The legacy in-process streaming path (SwapChainPanel + D3D11VA) instead of the
|
||||
/// spawned punktfunk-session window: the Settings "Streaming engine" pick, or the
|
||||
/// `PUNKTFUNK_BUILTIN_STREAM=1` env override. A temporary A/B knob — both go away with
|
||||
/// the legacy path once the Vulkan session is fully validated.
|
||||
pub(crate) fn use_builtin_stream(ctx: &AppCtx) -> bool {
|
||||
/// spawned punktfunk-session window: the `PUNKTFUNK_BUILTIN_STREAM=1` env override — a
|
||||
/// developer A/B knob only (the former Settings "Streaming engine" pick is gone), removed
|
||||
/// with the legacy path once the Vulkan session is fully validated.
|
||||
pub(crate) fn use_builtin_stream(_ctx: &AppCtx) -> bool {
|
||||
std::env::var_os("PUNKTFUNK_BUILTIN_STREAM").is_some_and(|v| v == "1")
|
||||
|| ctx.settings.lock().unwrap().engine == "builtin"
|
||||
}
|
||||
|
||||
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||
@@ -158,10 +160,25 @@ pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_react
|
||||
gamepad,
|
||||
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();
|
||||
App::new()
|
||||
.title("Punktfunk")
|
||||
.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)
|
||||
.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);
|
||||
// Which Settings section the NavigationView shows (persists across visits this run).
|
||||
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).
|
||||
cx.use_effect((), {
|
||||
@@ -392,6 +425,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
svc,
|
||||
hosts,
|
||||
status,
|
||||
pads,
|
||||
forget,
|
||||
rename,
|
||||
show_add,
|
||||
|
||||
@@ -50,6 +50,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
port: target3.port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
last_used: None,
|
||||
mac: target3.mac.clone(),
|
||||
});
|
||||
let _ = k.save();
|
||||
|
||||
@@ -26,11 +26,6 @@ const DECODERS: &[(&str, &str)] = &[
|
||||
("vulkan", "Hardware (GPU / Vulkan Video)"),
|
||||
("software", "Software (CPU)"),
|
||||
];
|
||||
/// Temporary A/B knob (see `Settings::engine`) — deleted with the legacy path.
|
||||
const ENGINES: &[(&str, &str)] = &[
|
||||
("", "Vulkan session window (recommended)"),
|
||||
("builtin", "Built-in D3D11VA (legacy)"),
|
||||
];
|
||||
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
||||
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
||||
@@ -182,6 +177,13 @@ pub(crate) fn settings_page(
|
||||
"Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
|
||||
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 -----------------------------------------------------------------------------
|
||||
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; \
|
||||
SDR content is unaffected.",
|
||||
);
|
||||
let (eng_names, eng_i) = presets(ENGINES, |v| *v == s.engine);
|
||||
let engine_combo = setting_combo(ctx, "Streaming engine", eng_names, eng_i, |s, i| {
|
||||
s.engine = ENGINES[i].0.to_string();
|
||||
})
|
||||
.tooltip(
|
||||
"Temporary: compare the Vulkan session window against the legacy in-process \
|
||||
D3D11VA presenter. Applies to the next stream. This option goes away once the \
|
||||
Vulkan path is fully validated.",
|
||||
);
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently connected;
|
||||
// pinning survives until the app exits (Swift/GTK parity).
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently connected.
|
||||
// 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 (fwd_names, fwd_i) = {
|
||||
let mut names = vec!["Automatic (most recent)".to_string()];
|
||||
@@ -270,26 +264,32 @@ pub(crate) fn settings_page(
|
||||
format!("{} \u{00B7} {kind}", p.name)
|
||||
}
|
||||
}));
|
||||
let i = ctx
|
||||
.gamepad
|
||||
.pinned()
|
||||
.and_then(|id| pads.iter().position(|p| p.id == id))
|
||||
let i = (!s.forward_pad.is_empty())
|
||||
.then(|| pads.iter().position(|p| p.key == s.forward_pad))
|
||||
.flatten()
|
||||
.map_or(0, |i| i + 1);
|
||||
(names, i)
|
||||
};
|
||||
let forward_combo = {
|
||||
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)
|
||||
.header("Forwarded controller")
|
||||
.selected_index(fwd_i as i32)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let sel = i.max(0) as usize;
|
||||
svc.set_pinned(if sel == 0 {
|
||||
let key = if sel == 0 {
|
||||
None
|
||||
} 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(
|
||||
"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.");
|
||||
|
||||
let hud_toggle = setting_toggle(ctx, "Show the stats overlay (HUD)", s.show_hud, |s, on| {
|
||||
s.show_hud = on
|
||||
})
|
||||
let hud_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Show the stats overlay (HUD)",
|
||||
s.show_stats,
|
||||
|s, on| s.show_stats = on,
|
||||
)
|
||||
.tooltip(
|
||||
"The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \
|
||||
Ctrl+Alt+Shift+S toggles it live while streaming.",
|
||||
@@ -356,7 +359,6 @@ pub(crate) fn settings_page(
|
||||
bitrate_box.into(),
|
||||
hdr_toggle.into(),
|
||||
hud_toggle.into(),
|
||||
engine_combo.into(),
|
||||
]);
|
||||
controls
|
||||
}),
|
||||
@@ -376,7 +378,12 @@ pub(crate) fn settings_page(
|
||||
"about" => ("About", settings_card(vec![licenses_button.into()])),
|
||||
_ => (
|
||||
"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);
|
||||
cx.use_effect_with_cleanup((), {
|
||||
let shared = ctx.shared.clone();
|
||||
let (inhibit, show_hud) = {
|
||||
let (inhibit, show_stats) = {
|
||||
let s = ctx.settings.lock().unwrap();
|
||||
(s.inhibit_shortcuts, s.show_hud)
|
||||
(s.inhibit_shortcuts, s.show_stats)
|
||||
};
|
||||
let connector_ref = connector_ref.clone();
|
||||
move || {
|
||||
@@ -91,7 +91,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
let clock_offset = connector.clock_offset_ns;
|
||||
connector_ref.set(Some(connector.clone()));
|
||||
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(|| {
|
||||
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 {
|
||||
use super::style::{avatar, card, pill, Pill};
|
||||
let host = ctx.shared.target.lock().unwrap().name.clone();
|
||||
let title = if host.is_empty() {
|
||||
"Streaming".to_string()
|
||||
} else {
|
||||
format!("Streaming to {host}")
|
||||
let browse = ctx.shared.browse.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let title = match (browse, host.is_empty()) {
|
||||
(true, true) => "Console library".to_string(),
|
||||
(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).
|
||||
@@ -179,9 +181,11 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
vstack((
|
||||
text_block(&title).font_size(18.0).semibold(),
|
||||
text_block("The stream runs in its own window \u{2014} click it to capture input.")
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
text_block(
|
||||
"The stream has its own window \u{2014} this one returns when the session ends.",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(2.0)
|
||||
.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.
|
||||
let mut body: Vec<Element> = vec![header];
|
||||
if hud.stats_line.is_empty() {
|
||||
body.push(
|
||||
text_block("Waiting for the first stats window\u{2026}")
|
||||
.font_size(11.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.into(),
|
||||
);
|
||||
// The child prints `stats:` lines only while its stats view is on (the Settings
|
||||
// toggle / Ctrl+Alt+Shift+S) — say so instead of waiting forever. Browse idles in
|
||||
// the library between launches, so no stats there is simply normal: no line.
|
||||
if !browse {
|
||||
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 {
|
||||
let mut segments = hud.stats_line.split(" | ");
|
||||
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",
|
||||
)
|
||||
.font_size(11.0)
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.margin(edges(0.0, 4.0, 0.0, 0.0))
|
||||
.into(),
|
||||
@@ -253,9 +268,12 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
|
||||
.into()
|
||||
});
|
||||
|
||||
// One centred card, sized like the app's dialogs.
|
||||
border(card(vstack(body).spacing(12.0).width(520.0)))
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
// One centred card, sized like the app's dialogs — Stretch + max_width (the `page`
|
||||
// pattern) instead of a fixed width, so a narrow window shrinks the card instead of
|
||||
// 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)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -117,16 +117,22 @@ pub(crate) fn busy_page(headline: &str, detail: &str, extra: Vec<Element>) -> El
|
||||
text_block(headline)
|
||||
.font_size(18.0)
|
||||
.semibold()
|
||||
.wrap()
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.into(),
|
||||
text_block(detail)
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.into(),
|
||||
];
|
||||
children.extend(extra);
|
||||
// max_width + side margins so the text column reads well wide AND wraps instead of
|
||||
// clipping narrow.
|
||||
vstack(children)
|
||||
.spacing(16.0)
|
||||
.max_width(520.0)
|
||||
.margin(edges(24.0, 0.0, 24.0, 0.0))
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into()
|
||||
|
||||
@@ -31,8 +31,10 @@ const G: f32 = 9.80665;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
/// SDL joystick instance id — the settings GUI's pin key.
|
||||
pub id: u32,
|
||||
/// Stable identity (`vid:pid:name`, the same format as `pf-client-core`'s `PadInfo::key`)
|
||||
/// — 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,
|
||||
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
|
||||
/// 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 {
|
||||
Attach(Arc<NativeClient>),
|
||||
Detach,
|
||||
Pin(Option<u32>),
|
||||
Pin(Option<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GamepadService {
|
||||
pads: Arc<Mutex<Vec<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
|
||||
// WinUI app shares it across the UI thread and the session-pump thread (attach/detach).
|
||||
ctl: Arc<Mutex<Sender<Ctl>>>,
|
||||
@@ -91,13 +92,12 @@ impl GamepadService {
|
||||
pub fn start() -> GamepadService {
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
let active = Arc::new(Mutex::new(None));
|
||||
let pinned = Arc::new(Mutex::new(None));
|
||||
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()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.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");
|
||||
}
|
||||
})
|
||||
@@ -107,7 +107,6 @@ impl GamepadService {
|
||||
GamepadService {
|
||||
pads,
|
||||
active,
|
||||
pinned,
|
||||
ctl: Arc::new(Mutex::new(ctl)),
|
||||
}
|
||||
}
|
||||
@@ -121,13 +120,11 @@ impl GamepadService {
|
||||
self.active.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// The user-pinned controller (settings GUI), if any — else auto (most recent).
|
||||
pub fn pinned(&self) -> Option<u32> {
|
||||
*self.pinned.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_pinned(&self, id: Option<u32>) {
|
||||
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(id));
|
||||
/// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
|
||||
/// The pin survives the pad disconnecting: it re-applies the moment a matching
|
||||
/// 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 attach(&self, connector: Arc<NativeClient>) {
|
||||
@@ -251,7 +248,9 @@ struct Worker {
|
||||
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||||
/// Connection order; the most recently connected is the auto selection.
|
||||
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>>,
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
@@ -265,7 +264,14 @@ struct Worker {
|
||||
impl Worker {
|
||||
fn active_id(&self) -> Option<u32> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -275,16 +281,18 @@ impl Worker {
|
||||
self.subsystem
|
||||
.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,
|
||||
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
|
||||
if pad.vendor_id() == Some(0x28DE)
|
||||
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
||||
{
|
||||
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
let name = pad.name().unwrap_or_else(|| "Controller".into());
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
// Must match pf-client-core's `PadInfo::key` byte-for-byte — the persisted
|
||||
// `forward_pad` is applied by BOTH services (this one and the session's).
|
||||
key: format!("{vid:04x}:{pid:04x}:{name}"),
|
||||
name,
|
||||
pref,
|
||||
})
|
||||
}
|
||||
@@ -399,7 +407,6 @@ impl Worker {
|
||||
fn run(
|
||||
pads_out: &Mutex<Vec<PadInfo>>,
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
pinned_out: &Mutex<Option<u32>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
) -> Result<(), String> {
|
||||
// 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
|
||||
*pads_out.lock().unwrap() = list;
|
||||
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
|
||||
*pinned_out.lock().unwrap() = w.pinned;
|
||||
};
|
||||
|
||||
loop {
|
||||
@@ -448,9 +454,9 @@ fn run(
|
||||
w.set_sensors(false);
|
||||
w.attached = None;
|
||||
}
|
||||
Ok(Ctl::Pin(id)) => {
|
||||
Ok(Ctl::Pin(key)) => {
|
||||
let before = w.active_id();
|
||||
w.pinned = id;
|
||||
w.pinned = key;
|
||||
if w.active_id() != before {
|
||||
w.flush_held();
|
||||
if w.attached.is_some() {
|
||||
|
||||
@@ -90,7 +90,7 @@ static KBD_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).
|
||||
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
|
||||
/// reads it lock-free to drive the overlay.
|
||||
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.
|
||||
/// `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.
|
||||
pub fn install(
|
||||
connector: Arc<NativeClient>,
|
||||
mode: Mode,
|
||||
inhibit_shortcuts: bool,
|
||||
show_hud: bool,
|
||||
show_stats: bool,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
HUD_VISIBLE.store(show_hud, Ordering::Relaxed);
|
||||
HUD_VISIBLE.store(show_stats, Ordering::Relaxed);
|
||||
let hwnd = unsafe { GetForegroundWindow() };
|
||||
let mut st = State {
|
||||
connector,
|
||||
|
||||
@@ -39,6 +39,8 @@ mod render;
|
||||
#[cfg(windows)]
|
||||
mod session;
|
||||
#[cfg(windows)]
|
||||
mod shell_window;
|
||||
#[cfg(windows)]
|
||||
mod spawn;
|
||||
#[cfg(windows)]
|
||||
mod trust;
|
||||
@@ -58,6 +60,7 @@ fn main() {
|
||||
use windows::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS};
|
||||
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
}
|
||||
set_app_user_model_id();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.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
|
||||
/// Windows analogue of `punktfunk-probe`.
|
||||
#[cfg(windows)]
|
||||
@@ -192,6 +216,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
});
|
||||
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`
|
||||
//! routed to the re-pair PIN ceremony, `stats:` lines to the session status page.
|
||||
//!
|
||||
//! The legacy in-process D3D11VA presenter remains reachable via the Settings
|
||||
//! "Streaming engine" pick or `PUNKTFUNK_BUILTIN_STREAM=1` (`app::use_builtin_stream`) —
|
||||
//! the A/B baseline until its deletion.
|
||||
//! The legacy in-process D3D11VA presenter remains reachable via the
|
||||
//! `PUNKTFUNK_BUILTIN_STREAM=1` env override (`app::use_builtin_stream`) — the
|
||||
//! developer A/B baseline until its deletion.
|
||||
|
||||
use std::io::BufRead as _;
|
||||
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
|
||||
/// `on_event` from a reader thread. The child is parked in `slot` so Disconnect/Cancel
|
||||
/// can kill it. `Err` = the spawn itself failed (binary missing?) — surfaced as a
|
||||
/// connect error by the caller.
|
||||
/// can kill it. `fullscreen` starts the stream window fullscreen (the Settings "Start
|
||||
/// 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(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
fp_hex: &str,
|
||||
connect_timeout_secs: u64,
|
||||
fullscreen: bool,
|
||||
launch: Option<&str>,
|
||||
slot: SessionChild,
|
||||
mut on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.arg("--connect")
|
||||
.arg(format!("{addr}:{port}"))
|
||||
.arg("--fp")
|
||||
.arg(fp_hex)
|
||||
.arg("--connect-timeout")
|
||||
.arg(connect_timeout_secs.to_string())
|
||||
.stdin(Stdio::null())
|
||||
.arg(connect_timeout_secs.to_string());
|
||||
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())
|
||||
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||
tracing::info!(host = %addr, port, "session binary spawned");
|
||||
tracing::info!(host = %host_label, "session binary spawned");
|
||||
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
// Park the child where the kill handle (and the reader, for the final reap) reach it.
|
||||
|
||||
+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
|
||||
//! directory — `%APPDATA%\punktfunk` (the Windows analogue of `~/.config/punktfunk`), shared
|
||||
//! with the Windows host's identity location. The identity files (`client-{cert,key}.pem`)
|
||||
//! keep the same names so the trust model is identical across the native clients.
|
||||
//! The shell is the settings file's only writer; the session only reads it. The shell's
|
||||
//! former private `Settings` copy (≤ 0.8.4: `show_hud`, `engine`) is gone — old files
|
||||
//! still load via a serde alias in core, and the legacy in-process presenter is now
|
||||
//! reachable only through `PUNKTFUNK_BUILTIN_STREAM=1` (see `app::use_builtin_stream`).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::quic::endpoint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, load_or_create_identity, parse_hex32, touch_last_used, KnownHost, KnownHosts,
|
||||
Settings,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user