diff --git a/Cargo.lock b/Cargo.lock index 49ab5d25..7ed4153d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3349,15 +3349,10 @@ name = "punktfunk-client-session" version = "0.22.0" dependencies = [ "anyhow", - "async-channel", - "glib-build-tools", - "gtk4", - "libadwaita", "pf-client-core", "pf-console-ui", "pf-presenter", "punktfunk-core", - "relm4", "serde_json", "tracing", "tracing-subscriber", diff --git a/clients/session/Cargo.toml b/clients/session/Cargo.toml index 2f19a15f..88cdcdd9 100644 --- a/clients/session/Cargo.toml +++ b/clients/session/Cargo.toml @@ -41,37 +41,16 @@ anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -# The GTK4/libadwaita session shell (app.rs, cli.rs, ui_*.rs, shortcuts.rs, spawn.rs). Those -# modules are `#[cfg(target_os = "linux")]` in main.rs, so their dependencies belong in a -# linux-only block — the Windows leg of this crate is a stub binary and must not pull GTK. -# -# Versions and features track clients/linux verbatim (gtk4 0.11 "v4_16", libadwaita 0.9 "v1_5"): -# the two crates compile the same widget vocabulary, and a version skew between them would show up -# as type mismatches through relm4 rather than as anything legible. -# -# glib / gio / gdk / pango are NOT listed on purpose — the sources reach them as `gtk::glib`, -# `gtk::gio`, `gtk::gdk` and `gtk::pango`, gtk4's own re-exports. Declaring them separately would -# risk resolving a DIFFERENT version of the same sys crate than the one gtk4 links against. -[target.'cfg(target_os = "linux")'.dependencies] -gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] } -adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] } -relm4 = { version = "0.11", features = ["libadwaita"] } -async-channel = "2" -# NOT optional here, unlike the shared block above where it hangs off `ui`. cli.rs's `--json` host -# listing is a CLI feature, not a console-UI one, so on Linux it is needed whether or not `ui` is -# on — and `--no-default-features` is a documented Linux build ("same streaming, stats on stdout -# only"), which without this simply did not compile. -serde_json = "1" +# This crate carries NO toolkit, deliberately: it is the renderer the shells spawn, and the +# `--no-default-features` build is what a minimal/embedded image installs. GTK4/libadwaita/relm4 +# belong to `clients/linux` (the shell) and must never appear here — a stray copy of the shell's +# modules landed in src/ once (b0ea1e6b) and dragging its dependencies in behind it is what let +# the wrong `main.rs` build and ship as `punktfunk-session`. # 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" -# Compiles data/ (the OS-mark symbolic icons) into the embedded gresource (build.rs) — -# needs `glib-compile-resources`, which ships with the GTK dev stack this crate needs anyway. -[target.'cfg(target_os = "linux")'.build-dependencies] -glib-build-tools = "0.22" - [lints] workspace = true diff --git a/clients/session/build.rs b/clients/session/build.rs index 1bd2e46c..b9687a7b 100644 --- a/clients/session/build.rs +++ b/clients/session/build.rs @@ -4,17 +4,8 @@ //! icon is the generic default). fn main() { - // Linux: compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic - // icons) into a gresource bundle, registered at startup via - // `gio::resources_register_include!`. Host-gated like the Windows leg below. - #[cfg(target_os = "linux")] - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { - glib_build_tools::compile_resources( - &["data"], - "data/resources.gresource.xml", - "punktfunk-client.gresource", - ); - } + // No Linux leg: this binary carries no toolkit, so it has no gresource to compile. The + // OS-mark icons belong to the GTK shell (`clients/linux/data`), which builds its own. // 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). diff --git a/clients/session/data/icons/scalable/actions/pf-os-apple-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-apple-symbolic.svg deleted file mode 100644 index 2af51e1a..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-apple-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/icons/scalable/actions/pf-os-arch-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-arch-symbolic.svg deleted file mode 100644 index 458911f1..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-arch-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/icons/scalable/actions/pf-os-debian-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-debian-symbolic.svg deleted file mode 100644 index 9b82e206..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-debian-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/icons/scalable/actions/pf-os-fedora-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-fedora-symbolic.svg deleted file mode 100644 index 428abe6a..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-fedora-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/icons/scalable/actions/pf-os-linux-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-linux-symbolic.svg deleted file mode 100644 index 0f806c24..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-linux-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/icons/scalable/actions/pf-os-nixos-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-nixos-symbolic.svg deleted file mode 100644 index cc34d519..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-nixos-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/icons/scalable/actions/pf-os-opensuse-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-opensuse-symbolic.svg deleted file mode 100644 index e52617ec..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-opensuse-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/icons/scalable/actions/pf-os-steam-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-steam-symbolic.svg deleted file mode 100644 index db35d17b..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-steam-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/icons/scalable/actions/pf-os-ubuntu-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-ubuntu-symbolic.svg deleted file mode 100644 index 7bb9ce4f..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-ubuntu-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/icons/scalable/actions/pf-os-windows-symbolic.svg b/clients/session/data/icons/scalable/actions/pf-os-windows-symbolic.svg deleted file mode 100644 index f7f972bf..00000000 --- a/clients/session/data/icons/scalable/actions/pf-os-windows-symbolic.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/clients/session/data/resources.gresource.xml b/clients/session/data/resources.gresource.xml deleted file mode 100644 index 660e71e4..00000000 --- a/clients/session/data/resources.gresource.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - icons/scalable/actions/pf-os-windows-symbolic.svg - icons/scalable/actions/pf-os-apple-symbolic.svg - icons/scalable/actions/pf-os-linux-symbolic.svg - icons/scalable/actions/pf-os-steam-symbolic.svg - icons/scalable/actions/pf-os-ubuntu-symbolic.svg - icons/scalable/actions/pf-os-fedora-symbolic.svg - icons/scalable/actions/pf-os-arch-symbolic.svg - icons/scalable/actions/pf-os-debian-symbolic.svg - icons/scalable/actions/pf-os-nixos-symbolic.svg - icons/scalable/actions/pf-os-opensuse-symbolic.svg - - diff --git a/clients/session/src/app.rs b/clients/session/src/app.rs deleted file mode 100644 index 5fef2feb..00000000 --- a/clients/session/src/app.rs +++ /dev/null @@ -1,1033 +0,0 @@ -//! The application shell as a relm4 component tree (phase 5 of punktfunk-planning -//! `linux-client-rearchitecture.md`): [`AppModel`] owns the window, navigation, trust -//! gate, and the spawned session child's lifecycle; the hosts page is a child component -//! ([`crate::ui_hosts`]); dialogs (trust, settings, library) are plain GTK invoked from -//! `update`. Every stream runs in the `punktfunk-session` Vulkan binary — the shell -//! never touches video. - -use crate::spawn::{self, SpawnOpts}; -use crate::trust::{self, Settings}; -use crate::ui_hosts::{ConnectRequest, HostsMsg, HostsOutput, HostsPage}; -use adw::prelude::*; -use gtk::{gdk, gio, glib}; -use punktfunk_core::client::NativeClient; -use punktfunk_core::config::{CompositorPref, GamepadPref}; -use relm4::prelude::*; -use std::cell::RefCell; -use std::rc::Rc; - -pub const APP_ID: &str = "io.unom.Punktfunk"; - -/// Custom styles on top of libadwaita for the host cards: status pills, presence pips, -/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita -/// named palette so dark mode just works. -const CSS: &str = " -.pf-host-card { padding: 16px; } -/* The FlowBoxChild draws the hover/selection highlight AROUND the card (it wraps it - with its own padding), so its corners must run concentric with the card's 12px — - radius = card radius + the child's padding ring. */ -.pf-host-grid > flowboxchild { border-radius: 15px; } -.pf-pill { font-size: 0.72em; font-weight: bold; padding: 2px 10px; border-radius: 999px; - color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); } -.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); } -.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); } -.pf-pill.pf-neutral { color: alpha(currentColor, 0.75); background: alpha(currentColor, 0.12); } -.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px; - background: alpha(currentColor, 0.35); } -.pf-pip.pf-online { background: @success_color; } -/* An overridden row in profile scope: an accent dot in the prefix, so which settings this - profile changes is legible at a glance without reading every value. (Plain string literal - -- a quote in here would end it.) */ -.pf-override-dot { min-width: 8px; min-height: 8px; border-radius: 999px; - background: @accent_color; } -/* Profile colour swatches (the accent a profile's chips carry). One class per palette entry - because a per-widget CSS provider for eight buttons is a lot of machinery for a dot. */ -.pf-swatch { min-width: 26px; min-height: 26px; border-radius: 999px; padding: 0; } -.pf-swatch-none { background: alpha(currentColor, 0.15); } -.pf-swatch-red { background: #e01b24; } -.pf-swatch-orange { background: #ff7800; } -.pf-swatch-yellow { background: #f6d32d; } -.pf-swatch-green { background: #33d17a; } -.pf-swatch-blue { background: #3584e4; } -.pf-swatch-purple { background: #9141ac; } -.pf-swatch-pink { background: #d16d9e; } -.pf-swatch-slate { background: #77767b; } -.pf-swatch-on { outline: 2px solid @accent_color; outline-offset: 2px; } -/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's - rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves - the card's own elevation shadow intact. */ -.pf-recent { outline: 2px solid @accent_color; outline-offset: -2px; } -.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); } -.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); } -.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); } -.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); } -"; - -/// Everything the shell shares below the component tree. -pub struct AppModel { - pub window: adw::ApplicationWindow, - pub nav: adw::NavigationView, - toasts: adw::ToastOverlay, - pub settings: Rc>, - pub identity: (String, String), - /// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams - /// run in the session binary, which has its own. - pub gamepad: crate::gamepad::GamepadService, - /// Device lists for the settings pickers (GPUs via `punktfunk-session - /// --list-adapters` — the shell deliberately links no Vulkan itself — and audio - /// endpoints via the PipeWire registry), probed once at startup on a worker thread. - /// Empty until the probe lands — empty lists simply hide their pickers. - pub probes: Rc>, - hosts: Controller, - /// One session child at a time — connects while one runs are ignored. - busy: bool, - /// Armed by [`AppMsg::WakeConnect`] (a dial to a host that isn't advertising but has a - /// known MAC): if THAT dial's child exits with a connect failure, `SessionExited` falls - /// back into the visible wake-and-wait instead of an error. Consumed on the next exit and - /// matched against the exiting request, so it can never redirect an unrelated failure. - wake_fallback: Option, - /// The request-access "waiting for approval" dialog, closed on the first child - /// event. A shared slot (not a message): dialogs are main-thread GTK objects and - /// `AppMsg` must stay `Send` for the session child's reader thread. - waiting: Rc>>, -} - -#[derive(Debug)] -pub enum AppMsg { - /// A `punktfunk://` URL arrived (scheme handler, a shortcut, or a second invocation - /// forwarded to this instance by GApplication) — design/client-deep-links.md §4.1. - DeepLink(String), - /// The trust gate in front of every connect (rules 1–3, see `update`). - Connect(ConnectRequest), - /// Connect to a saved host that isn't advertising but has a known MAC: fire a wake - /// packet and DIAL IMMEDIATELY (mDNS absence ≠ unreachable — routed/Tailscale hosts - /// never advertise here); only a failed dial falls into the visible wake-and-wait. - WakeConnect(ConnectRequest), - /// The SPAKE2 PIN ceremony dialog. - Pair(ConnectRequest), - SpeedTest(ConnectRequest), - /// The desktop library page (mgmt port from the live advert when known). - OpenLibrary(ConnectRequest, Option), - /// Spawn the session child now (trust already decided; `tofu` = persist the - /// fingerprint once the child proves it). - StartSession { - req: ConnectRequest, - fp_hex: String, - tofu: bool, - opts: SpawnOpts, - }, - /// The child presented its first frame. - SessionReady { - req: ConnectRequest, - fp_hex: String, - tofu: bool, - persist_paired: bool, - }, - /// The child exited (the session is over, or the connect failed). - SessionExited { - req: ConnectRequest, - code: i32, - error: Option<(String, bool)>, - ended: Option, - tofu: bool, - }, - /// Request-access Cancel: the child was killed; release busy quietly. - CancelPending, - /// The speed-test dialog resolved (either way) — release `busy`. - SpeedTestDone, - ShowPreferences, - /// Re-open Preferences editing a specific layer — the settings scope switcher's - /// destination (design/client-settings-profiles.md §5.1). - ShowPreferencesScoped(crate::ui_settings::Scope), - ShowShortcuts, - ShowAbout, - ShowAddHost, - Toast(String), -} - -pub struct AppInit { - pub gamepad: crate::gamepad::GamepadService, -} - -pub struct AppWidgets {} - -impl SimpleComponent for AppModel { - type Init = AppInit; - type Input = AppMsg; - type Output = (); - type Root = adw::ApplicationWindow; - type Widgets = AppWidgets; - - fn init_root() -> Self::Root { - adw::ApplicationWindow::builder() - .title("Punktfunk") - .default_width(1200) - .default_height(780) - .build() - } - - fn init( - init: Self::Init, - window: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let identity = match trust::load_or_create_identity() { - Ok(i) => i, - Err(e) => { - tracing::error!("client identity: {e:#}"); - std::process::exit(1); - } - }; - load_css(); - install_os_icons(); - // Screenshot scenes must capture settled frames: kill every GTK/libadwaita - // animation (a headless session may starve the frame clock and leave a - // transition frozen mid-flight in the capture). - if crate::cli::shot_scene().is_some() { - if let Some(s) = gtk::Settings::default() { - s.set_gtk_enable_animations(false); - } - } - - let settings = Rc::new(RefCell::new(Settings::load())); - // Device lists for the settings pickers: probe in the background, ready long - // before the dialog opens. A missing session binary or absent PipeWire just - // leaves the corresponding list empty (and its picker hidden). - let probes: Rc> = Rc::default(); - { - let (tx, rx) = async_channel::bounded::(1); - std::thread::spawn(move || { - let adapters: Vec = - std::process::Command::new(crate::spawn::session_binary()) - .arg("--list-adapters") - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| { - String::from_utf8_lossy(&o.stdout) - .lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(); - let (speakers, mics) = pf_client_core::audio::devices().unwrap_or_default(); - let _ = tx.send_blocking(crate::ui_settings::DeviceProbes { - adapters, - speakers, - mics, - }); - }); - let probes = probes.clone(); - glib::spawn_future_local(async move { - if let Ok(found) = rx.recv().await { - *probes.borrow_mut() = found; - } - }); - } - // Re-apply the persisted forwarded-controller pin (stable key; the service - // matches it whenever such a pad connects). - { - let forward = settings.borrow().forward_pad.clone(); - if !forward.is_empty() { - init.gamepad.set_pinned(Some(forward)); - } - } - - let hosts = - HostsPage::builder() - .launch(settings.clone()) - .forward(sender.input_sender(), |out| match out { - HostsOutput::Connect(req) => AppMsg::Connect(req), - HostsOutput::WakeConnect(req) => AppMsg::WakeConnect(req), - HostsOutput::Pair(req) => AppMsg::Pair(req), - HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req), - HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt), - HostsOutput::Toast(msg) => AppMsg::Toast(msg), - }); - - let nav = adw::NavigationView::new(); - nav.add(hosts.widget()); - let toasts = adw::ToastOverlay::new(); - toasts.set_child(Some(&nav)); - window.set_content(Some(&toasts)); - // Gaming-mode fallback (a bare launch under gamescope): fullscreen the shell. - if crate::cli::fullscreen_mode() { - window.fullscreen(); - } - - let model = AppModel { - window: window.clone(), - nav, - toasts, - settings, - identity, - gamepad: init.gamepad, - probes, - hosts, - busy: false, - wake_fallback: None, - waiting: Rc::new(RefCell::new(None)), - }; - install_actions(&model.window, &sender); - - // CI screenshot mode: dispatch the scripted scene once the window is actually - // mapped (AdwDialogs need a live window; relm4 maps it after `init` returns, so - // this can't run inline like the pre-relm4 `activate` path did). - if let Some(scene) = crate::cli::shot_scene() { - let ctx = crate::cli::ShotCtx { - window: model.window.clone(), - nav: model.nav.clone(), - hosts: model.hosts.sender().clone(), - settings: model.settings.clone(), - gamepad: model.gamepad.clone(), - identity: model.identity.clone(), - sender: sender.clone(), - }; - let fired = std::cell::Cell::new(false); - model.window.connect_map(move |_| { - if fired.replace(true) { - return; // map can fire more than once; the scene runs on the first - } - crate::cli::run_shot(&ctx, &scene); - }); - } - window.present(); - - // The deep-link seam is live from here: anything GApplication delivered during a cold - // start has been parked, and everything from now on arrives as a message. - LINK_TX.with_borrow_mut(|tx| *tx = Some(sender.input_sender().clone())); - for url in PENDING_LINKS.with_borrow_mut(std::mem::take) { - sender.input(AppMsg::DeepLink(url)); - } - - ComponentParts { - model, - widgets: AppWidgets {}, - } - } - - fn update(&mut self, msg: AppMsg, sender: ComponentSender) { - match msg { - AppMsg::DeepLink(url) => self.open_deep_link(&url, &sender), - // The trust gate (the host is the policy authority — it advertises - // `pair=optional` only when it accepts unpaired clients): - // 1. PINNED RECONNECT — a stored fingerprint connects silently. - // 2. FINGERPRINT CHANGED — known address, different fp: the impostor - // signal; force the PIN ceremony. - // 3a. NEW + pair=optional — offer TOFU alongside PIN. - // 3b. NEW otherwise — delegated approval (request access) or PIN. - AppMsg::Connect(req) => { - if self.busy { - return; - } - let known = trust::KnownHosts::load(); - match &req.fp_hex { - Some(fp_hex) => { - if known.find_by_fp(fp_hex).is_some() { - let fp_hex = fp_hex.clone(); - sender.input(AppMsg::StartSession { - req, - fp_hex, - tofu: false, - opts: SpawnOpts::default(), - }); - } else if known.find_by_addr(&req.addr, req.port).is_some() { - self.toast("Host fingerprint changed — re-pair with a PIN to continue"); - crate::ui_trust::pin_dialog( - &self.window, - &sender, - self.identity.clone(), - req, - ); - } else if req.pair_optional { - crate::ui_trust::tofu_dialog(&self.window, &sender, req); - } else { - crate::ui_trust::approval_dialog( - &self.window, - &sender, - self.waiting.clone(), - req, - ); - } - } - None => { - // Manual entry: a known address connects on its stored pin; - // an unknown one must pair — never silent TOFU. - match known - .find_by_addr(&req.addr, req.port) - .map(|k| k.fp_hex.clone()) - { - Some(fp_hex) => sender.input(AppMsg::StartSession { - req, - fp_hex, - tofu: false, - opts: SpawnOpts::default(), - }), - None => crate::ui_trust::approval_dialog( - &self.window, - &sender, - self.waiting.clone(), - req, - ), - } - } - } - } - AppMsg::WakeConnect(req) => { - if !self.busy { - // DIAL FIRST — no mDNS advert does NOT mean unreachable: a host reached over - // a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and - // gating the dial on presence bricked exactly those reconnects. Fire the magic - // packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep - // box is already booting while the dial times out, arm the wake-wait fallback - // for THIS request, and connect immediately. - // - // Auto-wake OFF (the Settings toggle, for VPN hosts that look offline when - // they aren't): no packet and no wake-and-wait fallback — the dial either - // succeeds or fails with the normal error. The host-card menu's explicit - // "Wake host" is deliberately not gated. - if self.settings.borrow().auto_wake { - crate::wol::wake(&req.mac, req.addr.parse().ok()); - self.wake_fallback = Some(req.clone()); - } - sender.input(AppMsg::Connect(req)); - } - } - AppMsg::Pair(req) => { - if !self.busy { - crate::ui_trust::pin_dialog(&self.window, &sender, self.identity.clone(), req); - } - } - AppMsg::SpeedTest(req) => self.speed_test(req, &sender), - AppMsg::SpeedTestDone => self.busy = false, - AppMsg::OpenLibrary(req, mgmt_port) => { - crate::ui_library::open(self, &sender, req, mgmt_port); - } - AppMsg::StartSession { - req, - fp_hex, - tofu, - opts, - } => { - if std::mem::replace(&mut self.busy, true) { - return; - } - self.hosts.emit(HostsMsg::ClearError); - self.hosts - .emit(HostsMsg::SetConnecting(Some(req.card_key()))); - let fullscreen = self.settings.borrow().fullscreen_on_stream; - if let Err(e) = spawn::spawn_session( - sender.input_sender().clone(), - req, - fp_hex, - tofu, - fullscreen, - opts, - ) { - self.busy = false; - self.hosts.emit(HostsMsg::SetConnecting(None)); - self.hosts.emit(HostsMsg::ShowError(e)); - } - } - AppMsg::SessionReady { - req, - fp_hex, - tofu, - persist_paired, - } => { - self.close_waiting(); - self.hosts.emit(HostsMsg::SetConnecting(None)); - if persist_paired { - // Request-access: the operator approved this device — a trusted - // PAIRED host from now on, like after a PIN ceremony. - trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true); - self.toast("Approved — connected"); - } else if tofu { - // The advertised fingerprint proved itself on a real connect. - trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, false); - self.toast(&format!( - "Trusted on first use — fingerprint {}…", - &fp_hex[..16.min(fp_hex.len())] - )); - } - self.hosts.emit(HostsMsg::Refresh); - } - AppMsg::SessionExited { - req, - code, - error, - ended, - tofu, - } => { - self.close_waiting(); - self.busy = false; - self.hosts.emit(HostsMsg::SetConnecting(None)); - // The dial-first wake fallback (armed by `WakeConnect`, consumed on every exit): - // a failed dial to the non-advertising host it was armed for falls into the - // visible wake-and-wait instead of an error alert. Matched by fingerprint (else - // address) so a stale armed request can never redirect another host's failure. - let wake_fb = - self.wake_fallback - .take() - .filter(|fb| match (&fb.fp_hex, &req.fp_hex) { - (Some(a), Some(b)) => a == b, - _ => fb.addr == req.addr && fb.port == req.port, - }); - match (code, error, ended) { - (0, _, None) => {} // clean end — back on the hosts page, no noise - (0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)), - (_, Some((_, true)), _) if !tofu => { - // The stored pin no longer matches (rotated cert or impostor). The host - // ANSWERED — never the wake fallback. - self.toast("Host fingerprint changed — re-pair with a PIN to continue"); - crate::ui_trust::pin_dialog( - &self.window, - &sender, - self.identity.clone(), - req, - ); - } - // A fingerprint mismatch means the host ANSWERED — reachable, so the plain - // error arms below handle it; only a genuine connect failure wakes. - (_, Some((_, false)), _) if wake_fb.is_some() => { - crate::ui_trust::wake_and_connect(&self.window, &sender, req) - } - (_, Some((msg, _)), _) => self - .hosts - .emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))), - (-1, None, _) => {} // killed (request-access cancel) — already handled - (_, None, _) if wake_fb.is_some() => { - crate::ui_trust::wake_and_connect(&self.window, &sender, req) - } - (code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!( - "Stream session failed (punktfunk-session exit {code})" - ))), - } - } - AppMsg::CancelPending => { - self.close_waiting(); - self.busy = false; - self.hosts.emit(HostsMsg::SetConnecting(None)); - self.toast("Cancelled — the request may still be pending on the host."); - } - AppMsg::ShowPreferences => sender.input(AppMsg::ShowPreferencesScoped( - crate::ui_settings::Scope::Defaults, - )), - AppMsg::ShowPreferencesScoped(scope) => { - let hosts = self.hosts.sender().clone(); - let reopen = sender.clone(); - crate::ui_settings::show_scoped( - &self.window, - self.settings.clone(), - &self.gamepad, - &self.probes.borrow(), - scope, - // The switcher closes the dialog to commit the layer it was editing, then - // asks for it back in the new scope — so the app owns the re-open and the - // dialog stays a pure view. - move |next| reopen.input(AppMsg::ShowPreferencesScoped(next)), - move || { - // The library toggle changes the saved cards' menu, and a profile edit - // changes the chips — re-render either way. - let _ = hosts.send(HostsMsg::Refresh); - }, - ); - } - AppMsg::ShowShortcuts => shortcuts_window(&self.window).present(), - AppMsg::ShowAbout => crate::ui_settings::show_about(&self.window), - AppMsg::ShowAddHost => self.hosts.emit(HostsMsg::ShowAddHost), - AppMsg::Toast(msg) => self.toast(&msg), - } - } -} - -impl AppModel { - pub fn toast(&self, msg: &str) { - self.toasts.add_toast(adw::Toast::new(msg)); - } - - /// Route a `punktfunk://` URL (design/client-deep-links.md §4.1). Parsing, host/profile - /// resolution and every refusal rule live in the shared brain (`plan_from_link`); this is - /// only the GTK end of it — turn the outcome into the same messages a card click raises, - /// so a link gets the identical wake, trust and error surfaces and NOT a second connect - /// path of its own. - fn open_deep_link(&mut self, url: &str, sender: &ComponentSender) { - use pf_client_core::deeplink; - use pf_client_core::orchestrate::{plan_from_link, PlanOutcome}; - use pf_client_core::profiles::ProfilesFile; - - tracing::debug!(%url, "deep link"); - let link = match deeplink::parse(url) { - Ok(l) => l, - Err(e) => return self.toast(&e.message()), - }; - let known = trust::KnownHosts::load(); - let outcome = plan_from_link( - &link, - &known, - &ProfilesFile::load(), - &self.settings.borrow(), - ); - match outcome { - Ok(PlanOutcome::Connect(plan)) => { - // Rule 2 of §3: never preempt a live session. Only this layer knows one is - // running, which is why the brain leaves the check here. - if self.busy { - return self.toast("A session is already running — end it first."); - } - let req = ConnectRequest { - name: plan.host.name.clone(), - addr: plan.host.addr.clone(), - port: plan.host.port, - fp_hex: plan.host.fp_hex.clone(), - pair_optional: false, - launch: plan.launch.clone().map(|id| (id.clone(), id)), - mac: plan.host.mac.clone(), - // `profile=` in a URL is a one-off, exactly like "Connect with ▸": it - // shapes this session and leaves the host's binding alone. - profile: plan.profile_override.clone(), - }; - // A link is a launch like any other: with a MAC it takes the dial-first wake - // path, so a sleeping host wakes instead of erroring. - sender.input(if plan.wake { - AppMsg::WakeConnect(req) - } else { - AppMsg::Connect(req) - }); - } - Ok(PlanOutcome::ConfirmUnknown(unknown)) => { - // Known-but-unpinned, or not known at all: the link may not pair and may not - // trust on its own, so it opens the ordinary ceremony under the user's eyes — - // the PIN dialog, seeded with what the link claimed. - if self.busy { - return self.toast("A session is already running — end it first."); - } - let req = ConnectRequest { - name: unknown.name.clone().unwrap_or_else(|| unknown.addr.clone()), - addr: unknown.addr.clone(), - port: unknown.port, - fp_hex: unknown.fp.clone(), - pair_optional: false, - launch: unknown.launch.clone().map(|id| (id.clone(), id)), - mac: Vec::new(), - profile: None, - }; - self.toast(&format!( - "{} isn't paired with this device yet — pair it to continue.", - req.name - )); - crate::ui_trust::pin_dialog(&self.window, sender, self.identity.clone(), req); - } - Ok(PlanOutcome::Unsupported(route)) => self.toast(&format!( - "Punktfunk can't open “{}” links yet.", - route.as_str() - )), - Err(e) => self.toast(&e.message()), - } - } - - fn close_waiting(&mut self) { - if let Some(w) = self.waiting.borrow_mut().take() { - w.close(); - } - } - - /// Measure the path to a host over the real data plane: connect, burst probe filler - /// for 2 s, report goodput · loss · a recommended bitrate, and apply it in one tap. - fn speed_test(&mut self, req: ConnectRequest, sender: &ComponentSender) { - if std::mem::replace(&mut self.busy, true) { - return; - } - let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32); - let status = gtk::Label::new(Some("Connecting…")); - let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name)); - dialog.set_extra_child(Some(&status)); - // Where a measured bitrate belongs is "the layer this host actually resolves bitrate - // from" (design/client-settings-profiles.md §5.3) — the long-standing wrong answer was - // always the global, so measuring the slow retro box downstairs re-tuned the desktop - // too. The target depends only on the host, so it is known before the result lands and - // the button can say where it will write. - let target = SpeedTestTarget::resolve(&req); - match &target { - SpeedTestTarget::Global => { - dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]); - } - SpeedTestTarget::Profile(p) => { - dialog.add_responses(&[ - ("close", "Close"), - ("apply", &format!("Apply to “{}”", p.name)), - ]); - } - // A bound host whose profile doesn't override bitrate could legitimately mean - // either: the user gets both, rather than us guessing which layer they meant. - SpeedTestTarget::Ask(p) => { - dialog.add_responses(&[ - ("close", "Close"), - ("apply-global", "Set as default"), - ("apply", &format!("Set in “{}”", p.name)), - ]); - dialog.set_response_enabled("apply-global", false); - } - } - dialog.set_response_enabled("apply", false); - dialog.set_close_response("close"); - dialog.present(Some(&self.window)); - - let (tx, rx) = - async_channel::bounded::>(1); - let identity = self.identity.clone(); - let (host, port) = (req.addr.clone(), req.port); - std::thread::spawn(move || { - let result = (|| { - let c = NativeClient::connect( - &host, - port, - punktfunk_core::config::Mode { - width: 1280, - height: 720, - refresh_hz: 60, - }, - CompositorPref::Auto, - GamepadPref::Auto, - 0, // bitrate_kbps (host default) - 0, // video_caps: probe connect, nothing presents - 2, // audio_channels: stereo - crate::video::decodable_codecs(), // codecs (unused by the probe, but honest) - 0, // preferred_codec: no preference - None, // display_hdr: probe connect, nothing presents - 0, // client_caps: probe connect, nothing renders a cursor - None, // launch: probe connect, no game - // Knock under this device's name, not a fingerprint placeholder, when the - // probed host doesn't know us yet. - Some(pf_client_core::trust::device_name()), - pin, - Some(identity), - std::time::Duration::from_secs(15), - ) - .map_err(|e| format!("connect: {e:?}"))?; - c.request_probe(3_000_000, 2_000) - .map_err(|e| format!("probe: {e:?}"))?; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - loop { - std::thread::sleep(std::time::Duration::from_millis(250)); - let r = c.probe_result(); - if r.done { - // Let the last UDP shards land before tearing down. - std::thread::sleep(std::time::Duration::from_millis(400)); - return Ok(c.probe_result()); - } - if std::time::Instant::now() > deadline { - return Err("probe timed out".to_string()); - } - } - })(); - let _ = tx.send_blocking(result); - }); - - let settings = self.settings.clone(); - let toasts = self.toasts.clone(); - let sender = sender.clone(); - glib::spawn_future_local(async move { - let outcome = rx.recv().await; - sender.input(AppMsg::SpeedTestDone); - match outcome { - Ok(Ok(r)) => { - let mbps = f64::from(r.throughput_kbps) / 1000.0; - let recommended_kbps = r.throughput_kbps / 10 * 7; - status.set_text(&format!( - "{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s", - r.loss_pct, - f64::from(recommended_kbps) / 1000.0, - )); - dialog.set_response_enabled("apply", true); - dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested); - if matches!(target, SpeedTestTarget::Ask(_)) { - dialog.set_response_enabled("apply-global", true); - } - let mbit = f64::from(recommended_kbps) / 1000.0; - { - let (settings, toasts) = (settings.clone(), toasts.clone()); - dialog.connect_response(Some("apply"), move |_, _| { - let where_to = match &target { - SpeedTestTarget::Global => { - let mut s = settings.borrow_mut(); - s.bitrate_kbps = recommended_kbps; - s.save(); - "the default bitrate".to_string() - } - SpeedTestTarget::Profile(p) | SpeedTestTarget::Ask(p) => { - write_profile_bitrate(&p.id, recommended_kbps); - format!("“{}”", p.name) - } - }; - toasts.add_toast(adw::Toast::new(&format!( - "{mbit:.0} Mbit/s set in {where_to}" - ))); - }); - } - dialog.connect_response(Some("apply-global"), move |_, _| { - let mut s = settings.borrow_mut(); - s.bitrate_kbps = recommended_kbps; - s.save(); - toasts.add_toast(adw::Toast::new(&format!( - "{mbit:.0} Mbit/s set in the default bitrate" - ))); - }); - } - Ok(Err(msg)) => status.set_text(&msg), - Err(_) => {} - } - }); - } -} - -/// Which layer a measured bitrate should land in for the host that was tested -/// (design/client-settings-profiles.md §5.3). -enum SpeedTestTarget { - /// No profile bound — the global default, i.e. what has always happened. - Global, - /// The bound profile already overrides bitrate, so that override is what this host reads. - Profile(pf_client_core::profiles::StreamProfile), - /// Bound, but the profile inherits bitrate: writing either layer is defensible, so ask. - Ask(pf_client_core::profiles::StreamProfile), -} - -impl SpeedTestTarget { - fn resolve(req: &crate::ui_hosts::ConnectRequest) -> SpeedTestTarget { - // Resolved exactly the way a connect resolves it: the one-off pick this test was - // started with (a pinned card carries one), else the host's binding. - let bound = trust::KnownHosts::load() - .hosts - .iter() - .find(|h| h.addr == req.addr && h.port == req.port) - .and_then(|h| h.profile_id.clone()); - let reference = match req.profile.as_deref() { - Some("") => return SpeedTestTarget::Global, - Some(id) => Some(id.to_string()), - None => bound, - }; - let Some(reference) = reference else { - return SpeedTestTarget::Global; - }; - let catalog = pf_client_core::profiles::ProfilesFile::load(); - match catalog.resolve(&reference).0 { - Some(p) if p.overrides.bitrate_kbps.is_some() => SpeedTestTarget::Profile(p.clone()), - Some(p) => SpeedTestTarget::Ask(p.clone()), - // A dangling binding resolves as no profile everywhere else; here too. - None => SpeedTestTarget::Global, - } - } -} - -/// Write a measured bitrate into one profile's overlay, leaving everything else alone. -fn write_profile_bitrate(id: &str, kbps: u32) { - let mut catalog = pf_client_core::profiles::ProfilesFile::load(); - let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) else { - return; // deleted while the test ran — the toast still tells the truth about the test - }; - p.overrides.bitrate_kbps = Some(kbps); - if let Err(e) = catalog.save() { - tracing::warn!(error = %format!("{e:#}"), "saving the measured bitrate"); - } -} - -thread_local! { - /// Where a delivered URL goes once the window exists. Both ends of this live on the GTK - /// main thread: `connect_open` fires there, and so does the model's `init`. - static LINK_TX: std::cell::RefCell>> = - const { std::cell::RefCell::new(None) }; - /// URLs that arrived before the model existed — the cold-start case, where GApplication - /// runs `open` before `activate` builds the window. A dropped URL is the one outcome a - /// link must never have, so they wait here instead. - static PENDING_LINKS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; -} - -/// Hand a URL to the running app, or park it until there is one. -fn deliver_deep_link(url: String) { - let queued = LINK_TX.with_borrow(|tx| match tx { - Some(tx) => { - let _ = tx.send(AppMsg::DeepLink(url.clone())); - false - } - None => true, - }); - if queued { - PENDING_LINKS.with_borrow_mut(|q| q.push(url)); - } -} - -pub fn run() -> glib::ExitCode { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), - ) - .init(); - // Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every - // physical pad Steam Input has virtualized; the Settings controller list needs the - // real devices (same rationale as the session binary). - for var in [ - "SDL_GAMECONTROLLER_IGNORE_DEVICES", - "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT", - ] { - if let Ok(v) = std::env::var(var) { - tracing::info!(var, value = %v, "clearing Steam's SDL device filter"); - std::env::remove_var(var); - } - } - // Headless paths (no GTK window). - if let Some(pin) = crate::cli::arg_value("--pair") { - return crate::cli::headless_pair(&pin); - } - if let Some(target) = crate::cli::arg_value("--library") { - return crate::cli::headless_library(&target); - } - if crate::cli::arg_value("--wake").is_some() { - return crate::cli::cli_wake(); - } - // Headless known-hosts management (list/add/edit/forget/reset) + reachability probes — - // the shared store the Decky plugin drives; returns None when argv names none of them. - if let Some(code) = crate::cli::headless_host_command() { - return code; - } - // Streams and the console library live in the session binary now — exec it, - // forwarding the relevant argv (the Decky wrapper keeps working through the shell - // until it's repointed). - // `--browse` may be bare now (the console home — hosts, pairing, settings), so the - // gate is the flag, not a value after it. - if crate::cli::arg_value("--connect").is_some() || crate::cli::arg_flag("--browse") { - return crate::cli::exec_session(); - } - - // HANDLES_OPEN is what makes `Exec=punktfunk-client %u` work: GApplication turns the URI - // into an `open` call, and — this is the part that matters — a SECOND invocation forwards - // its URI to the already-running instance over D-Bus and exits, so clicking a link with - // Punktfunk open reuses the window instead of racing a new one. - let mut builder = adw::Application::builder() - .application_id(APP_ID) - .flags(gio::ApplicationFlags::HANDLES_OPEN); - // Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps - // each launch its own primary instance. - if crate::cli::shot_scene().is_some() { - builder = - builder.flags(gio::ApplicationFlags::NON_UNIQUE | gio::ApplicationFlags::HANDLES_OPEN); - } - let adw_app = builder.build(); - adw_app.connect_open(|app, files, _hint| { - for f in files { - deliver_deep_link(f.uri().to_string()); - } - // `open` does not raise a window on its own; the model's activate handler does. - app.activate(); - }); - // One SDL context for the whole process, started while single-threaded. - let gamepad = crate::gamepad::GamepadService::start(); - // argv stays withheld from GApplication — except for a positional URL, which is exactly - // what GIO's single-instance forwarding is for. Passing it through means the FIRST - // instance's `open` fires locally and a later one's is delivered to the primary, with no - // IPC of our own. - let args: Vec = match crate::cli::deep_link_arg() { - Some(url) => vec![ - std::env::args() - .next() - .unwrap_or_else(|| "punktfunk-client".into()), - url, - ], - None => Vec::new(), - }; - let app = relm4::RelmApp::from_app(adw_app).with_args(args); - app.run::(AppInit { gamepad }); - glib::ExitCode::SUCCESS -} - -/// Register the embedded gresource (built by build.rs from `data/`) and point the icon -/// theme at it, so the host cards' `pf-os-*-symbolic` OS marks resolve — and recolor — -/// like any themed icon. -fn install_os_icons() { - if let Err(e) = gio::resources_register_include!("punktfunk-client.gresource") { - tracing::warn!("register gresource: {e} — host cards lose their OS marks"); - return; - } - if let Some(display) = gdk::Display::default() { - gtk::IconTheme::for_display(&display).add_resource_path("/io/unom/Punktfunk/icons"); - } -} - -fn load_css() { - let provider = gtk::CssProvider::new(); - provider.load_from_string(CSS); - if let Some(display) = gdk::Display::default() { - gtk::style_context_add_provider_for_display( - &display, - &provider, - gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - } -} - -/// Window actions behind the hosts page's header (the primary menu + "+") — thin -/// forwards into the message loop. -fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender) { - let add = |name: &str, msg: fn() -> AppMsg| { - let action = gio::SimpleAction::new(name, None); - let sender = sender.clone(); - action.connect_activate(move |_, _| sender.input(msg())); - action - }; - window.add_action(&add("preferences", || AppMsg::ShowPreferences)); - window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts)); - window.add_action(&add("about", || AppMsg::ShowAbout)); - window.add_action(&add("add-host", || AppMsg::ShowAddHost)); -} - -/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has -/// none); kept here as discoverable documentation. -pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow { - const UI: &str = r#" - - - 1 - - - - - Stream (session window) - - - Toggle fullscreen - F11 <Alt>Return - - - - - Release captured input (click the stream to capture) - <Control><Alt><Shift>q - - - - - Disconnect - <Control><Alt><Shift>d - - - - - Cycle the statistics overlay (off · compact · normal · detailed) - <Control><Alt><Shift>s - - - - - - - - -"#; - let builder = gtk::Builder::from_string(UI); - let window: gtk::ShortcutsWindow = builder - .object("shortcuts") - .expect("shortcuts window in builder XML"); - window.set_transient_for(Some(parent)); - window -} diff --git a/clients/session/src/cli.rs b/clients/session/src/cli.rs deleted file mode 100644 index 4946aa56..00000000 --- a/clients/session/src/cli.rs +++ /dev/null @@ -1,699 +0,0 @@ -//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the -//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot -//! scenes. - -use crate::app::AppModel; -use crate::trust::{forget_placeholder, KnownHost, KnownHosts}; -use crate::ui_hosts::{ConnectRequest, HostsMsg}; -use gtk::glib; -use gtk::prelude::*; -use punktfunk_core::client::NativeClient; -use relm4::prelude::*; -use std::cell::RefCell; -use std::rc::Rc; -use std::time::Duration; - -/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the -/// component parts, so the scene can be dispatched from the window's `map` callback. -pub struct ShotCtx { - pub window: adw::ApplicationWindow, - pub nav: adw::NavigationView, - pub hosts: relm4::Sender, - pub settings: Rc>, - pub gamepad: crate::gamepad::GamepadService, - pub identity: (String, String), - pub sender: ComponentSender, -} - -/// The value following `flag` in argv, if present (`--flag value`). -pub fn arg_value(flag: &str) -> Option { - std::env::args() - .skip_while(|a| a != flag) - .nth(1) - .filter(|v| !v.starts_with("--")) -} - -/// True if argv contains `flag` (a valueless switch). -pub fn arg_flag(flag: &str) -> bool { - std::env::args().any(|a| a == flag) -} - -/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — the deep-link -/// door (design/client-deep-links.md §4.1). It is positional, not a flag, because that is what -/// `Exec=punktfunk-client %u` hands us, what a `.desktop` shortcut embeds, and what a browser's -/// "Open Punktfunk?" prompt ends up invoking. Validation happens later, in the shared parser — -/// this only decides whether argv contains something addressed to us. -pub fn deep_link_arg() -> Option { - std::env::args().skip(1).find(|a| { - let lower = a.to_ascii_lowercase(); - lower.starts_with("punktfunk://") || lower.starts_with("pf://") - }) -} - -/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the -/// console library exec the session binary, which handles its own fullscreen). -pub fn fullscreen_mode() -> bool { - arg_flag("--fullscreen") - || std::env::var_os("SteamDeck").is_some() - || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() -} - -/// Split `host[:port]`: no colon defaults the port to 9777; a colon with an unparsable -/// port yields `None` for it (callers decide whether to default or bail). -fn parse_host_port(target: &str) -> (String, Option) { - match target.rsplit_once(':') { - Some((a, p)) => (a.to_string(), p.parse().ok()), - None => (target.to_string(), Some(9777)), - } -} - -/// `--connect` / `--browse`: streams and the console library live in the -/// `punktfunk-session` Vulkan binary — replace this process with it, forwarding the -/// relevant argv verbatim. This keeps the Decky wrapper (which launches the SHELL with -/// these flags) working unchanged until it's repointed at the session binary. -pub fn exec_session() -> glib::ExitCode { - use std::os::unix::process::CommandExt as _; - let forward = [ - "--connect", - "--browse", - "--fp", - "--launch", - "--mgmt", - "--connect-timeout", - ]; - let mut cmd = std::process::Command::new(crate::spawn::session_binary()); - let mut args = std::env::args().skip(1).peekable(); - while let Some(a) = args.next() { - if a == "--fullscreen" || a == "--stats" { - cmd.arg(a); - } else if forward.contains(&a.as_str()) { - cmd.arg(&a); - if let Some(v) = args.peek() { - if !v.starts_with("--") { - cmd.arg(args.next().unwrap()); - } - } - } - } - let err = cmd.exec(); // only returns on failure - eprintln!("exec punktfunk-session: {err}"); - glib::ExitCode::FAILURE -} - -/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the -/// known-hosts store as paired, so a later `--connect` connects silently. Same identity -/// store the streaming path uses, so pairing here makes the stream work. -/// Prints a one-line `paired : fp=` on success; exits non-zero on failure. -pub fn headless_pair(pin: &str) -> glib::ExitCode { - let Some(target) = arg_value("--connect") else { - eprintln!("--pair requires --connect host[:port]"); - return glib::ExitCode::FAILURE; - }; - let (addr, port) = parse_host_port(&target); - let port = port.unwrap_or(9777); - // The label the HOST stores this client under (its paired-devices list). - let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string()); - - let identity = match crate::trust::load_or_create_identity() { - Ok(i) => i, - Err(e) => { - eprintln!("client identity: {e:#}"); - return glib::ExitCode::FAILURE; - } - }; - match crate::trust::pair_with_host(&addr, port, &identity, pin, &name) { - Ok(fp) => { - let fp_hex = crate::trust::hex(&fp); - crate::trust::persist_host( - &arg_value("--host-label").unwrap_or_else(|| addr.clone()), - &addr, - port, - &fp_hex, - true, - ); - // A host manually added via `--add-host` (no fingerprint yet) is stored as an - // addr-keyed placeholder; now that the ceremony yielded the real fingerprint, - // `persist_host` created the fp-keyed entry — drop the placeholder so the list - // shows this host once, not twice. - forget_placeholder(&addr, port); - println!("paired {addr}:{port} fp={fp_hex}"); - glib::ExitCode::SUCCESS - } - Err(e) => { - eprintln!( - "pairing failed: {} ({e:?})", - crate::trust::pair_error_message(&e) - ); - glib::ExitCode::FAILURE - } - } -} - -/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, -/// without opening a window. The MAC comes from the known-hosts store (learned from the -/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet. -pub fn cli_wake() -> glib::ExitCode { - let Some(target) = arg_value("--wake") else { - eprintln!("--wake requires host[:port]"); - return glib::ExitCode::FAILURE; - }; - let (addr, port) = parse_host_port(&target); - let port = port.unwrap_or(9777); - let mac = crate::trust::KnownHosts::load() - .hosts - .iter() - .find(|h| h.addr == addr && h.port == port) - .map(|h| h.mac.clone()) - .unwrap_or_default(); - if mac.is_empty() { - eprintln!( - "--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \ - advertised MAC is learned" - ); - return glib::ExitCode::FAILURE; - } - crate::wol::wake(&mac, addr.parse().ok()); - println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len()); - glib::ExitCode::SUCCESS -} - -/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real -/// mTLS + pinned-fingerprint client, no GTK window. The pin comes from `--fp HEX` when -/// given, else the known-hosts store (matched by address), else none (TOFU-accept). -pub fn headless_library(target: &str) -> glib::ExitCode { - let (addr, port) = match target.rsplit_once(':') { - Some((a, p)) if p.parse::().is_ok() => (a.to_string(), p.parse().unwrap()), - _ => (target.to_string(), crate::library::DEFAULT_MGMT_PORT), - }; - let identity = match crate::trust::load_or_create_identity() { - Ok(i) => i, - Err(e) => { - eprintln!("client identity: {e:#}"); - return glib::ExitCode::FAILURE; - } - }; - let pin = arg_value("--fp") - .as_deref() - .and_then(crate::trust::parse_hex32) - .or_else(|| { - crate::trust::KnownHosts::load() - .hosts - .iter() - .find(|h| h.addr == addr) - .and_then(|h| crate::trust::parse_hex32(&h.fp_hex)) - }); - match crate::library::fetch_games(&addr, port, &identity, pin) { - Ok(games) => { - for g in &games { - println!("{}\t{}\t{}", g.id, g.store, g.title); - } - println!("{} game(s)", games.len()); - glib::ExitCode::SUCCESS - } - Err(e) => { - eprintln!("library: {e}"); - glib::ExitCode::FAILURE - } - } -} - -// ----------------------------------------------------------------------------------------- -// Headless host-store management — the shared known-hosts store (`client-known-hosts.json`) -// is the SINGLE source of truth for every client on this device (the GTK shell, the Vulkan -// session, and the Decky plugin, which shells out to these modes). Exposing add/edit/forget/ -// list/reset here lets the Decky Gaming-Mode UI mutate exactly the store the desktop client -// reads, so a change in one surface shows up in the other. Reachability (`--list-hosts -// --probe`, `--reachable`) answers "is this host online?" WITHOUT mDNS, so a host reached -// over a routed network (Tailscale/VPN/another subnet) no longer reads as offline. -// ----------------------------------------------------------------------------------------- - -/// The per-probe budget: a cold host on a routed link answers in well under this, and every -/// saved host is probed in parallel so the wall-clock cost is one timeout, not the sum. -const PROBE_TIMEOUT: Duration = Duration::from_millis(2500); - -/// Selector for `--set-host`/`--forget-host`: a 64-hex fingerprint pins one entry across IP -/// changes; anything else is treated as `addr[:port]` (manual entries have no fingerprint). -enum Selector { - Fp(String), - Addr(String, u16), -} - -fn parse_selector(s: &str) -> Selector { - if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) { - Selector::Fp(s.to_lowercase()) - } else { - let (addr, port) = parse_host_port(s); - Selector::Addr(addr, port.unwrap_or(9777)) - } -} - -impl Selector { - fn matches(&self, h: &KnownHost) -> bool { - match self { - Selector::Fp(fp) => h.fp_hex.eq_ignore_ascii_case(fp), - Selector::Addr(addr, port) => h.addr == *addr && h.port == *port, - } - } -} - -/// Probe every saved host for reachability in parallel (shared with the hosts-page presence pips). -fn probe_all(hosts: &[KnownHost]) -> Vec { - crate::trust::probe_reachable_many( - hosts.iter().map(|h| (h.addr.clone(), h.port)).collect(), - PROBE_TIMEOUT, - ) -} - -/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin -/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe -/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its -/// own mDNS view). Shape: `{"hosts":[{name,addr,port,fp_hex,paired,mac,last_used,online}]}`. -pub fn headless_list_hosts() -> glib::ExitCode { - let known = KnownHosts::load(); - let online: Option> = arg_flag("--probe").then(|| probe_all(&known.hosts)); - let hosts: Vec = known - .hosts - .iter() - .enumerate() - .map(|(i, h)| { - serde_json::json!({ - "name": h.name, - "addr": h.addr, - "port": h.port, - "fp_hex": h.fp_hex, - "paired": h.paired, - "mac": h.mac, - "os": h.os, - "last_used": h.last_used, - "online": online.as_ref().map(|v| serde_json::Value::Bool(v[i])) - .unwrap_or(serde_json::Value::Null), - }) - }) - .collect(); - match serde_json::to_string(&serde_json::json!({ "hosts": hosts })) { - Ok(s) => { - println!("{s}"); - glib::ExitCode::SUCCESS - } - Err(e) => { - eprintln!("list-hosts: {e}"); - glib::ExitCode::FAILURE - } - } -} - -/// `--reachable host[:port]` — probe one target and exit 0 (reachable) / 1 (not). A cheap -/// "is it online / test this address" check that never touches mDNS. -pub fn headless_reachable(target: &str) -> glib::ExitCode { - let (addr, port) = parse_host_port(target); - let port = port.unwrap_or(9777); - if NativeClient::probe(&addr, port, PROBE_TIMEOUT) { - println!("reachable {addr}:{port}"); - glib::ExitCode::SUCCESS - } else { - eprintln!("unreachable {addr}:{port}"); - glib::ExitCode::FAILURE - } -} - -/// `--add-host host[:port] [--host-label NAME] [--fp HEX]` — save a host by address so it can -/// be paired/streamed even when mDNS never sees it (a Tailscale/VPN box). Without `--fp` the -/// entry is an unpaired placeholder (keyed by address) the user pairs later — `headless_pair` -/// then replaces it with the fingerprinted entry. With `--fp` (e.g. carried from an advert) -/// it is pinned immediately as trusted-but-not-PIN-paired. Prints `added :`. -pub fn headless_add_host(target: &str) -> glib::ExitCode { - let (addr, port) = parse_host_port(target); - let port = port.unwrap_or(9777); - let name = arg_value("--host-label") - .map(|n| n.trim().to_string()) - .filter(|n| !n.is_empty()) - .unwrap_or_else(|| addr.clone()); - if let Some(fp_hex) = arg_value("--fp").filter(|f| crate::trust::parse_hex32(f).is_some()) { - // Fingerprint known up front: upsert the pinned entry (paired stays false — no PIN - // ceremony happened; a later `--pair` upgrades it). - crate::trust::persist_host(&name, &addr, port, &fp_hex.to_lowercase(), false); - forget_placeholder(&addr, port); - println!("added {addr}:{port} fp={}", fp_hex.to_lowercase()); - return glib::ExitCode::SUCCESS; - } - // No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists. - let mut known = KnownHosts::load(); - if let Some(h) = known - .hosts - .iter_mut() - .find(|h| h.addr == addr && h.port == port) - { - h.name = name; - } else { - known.hosts.push(KnownHost { - name, - addr: addr.clone(), - port, - ..Default::default() - }); - } - match known.save() { - Ok(()) => { - println!("added {addr}:{port}"); - glib::ExitCode::SUCCESS - } - Err(e) => { - eprintln!("add-host: {e:#}"); - glib::ExitCode::FAILURE - } - } -} - -/// `--set-host [--host-label NAME] [--addr ADDR] [--port PORT]` — edit a saved -/// host: rename and/or re-point its address. Identified by fingerprint (survives IP changes) or -/// current address. Prints `updated `; fails if nothing matched. -pub fn headless_set_host(selector: &str) -> glib::ExitCode { - let sel = parse_selector(selector); - let mut known = KnownHosts::load(); - let Some(h) = known.hosts.iter_mut().find(|h| sel.matches(h)) else { - eprintln!("set-host: no saved host matches {selector:?}"); - return glib::ExitCode::FAILURE; - }; - if let Some(name) = arg_value("--host-label").map(|n| n.trim().to_string()) { - if !name.is_empty() { - h.name = name; - } - } - if let Some(addr) = arg_value("--addr").map(|a| a.trim().to_string()) { - if !addr.is_empty() { - h.addr = addr; - } - } - if let Some(port) = arg_value("--port").and_then(|p| p.trim().parse::().ok()) { - h.port = port; - } - let label = h.name.clone(); - match known.save() { - Ok(()) => { - println!("updated {label}"); - glib::ExitCode::SUCCESS - } - Err(e) => { - eprintln!("set-host: {e:#}"); - glib::ExitCode::FAILURE - } - } -} - -/// `--forget-host ` — remove a saved host (drops the pinned fingerprint; a later -/// connect must re-pair/trust). Prints `forgot N`; succeeds even if nothing matched (idempotent). -pub fn headless_forget_host(selector: &str) -> glib::ExitCode { - let sel = parse_selector(selector); - let mut known = KnownHosts::load(); - let before = known.hosts.len(); - known.hosts.retain(|h| !sel.matches(h)); - let removed = before - known.hosts.len(); - if removed > 0 { - if let Err(e) = known.save() { - eprintln!("forget-host: {e:#}"); - return glib::ExitCode::FAILURE; - } - } - println!("forgot {removed}"); - glib::ExitCode::SUCCESS -} - -/// `--reset` — clear this device's client state: the saved known-hosts and the stream -/// settings. The persistent IDENTITY (`client-cert.pem`/`client-key.pem`) is deliberately -/// KEPT so the box isn't seen as a brand-new device everywhere (a re-pair still re-adds hosts); -/// a caller wanting a true factory reset removes those separately. Missing files are fine. -pub fn headless_reset() -> glib::ExitCode { - let Ok(dir) = crate::trust::config_dir() else { - eprintln!("reset: could not resolve config dir (HOME unset?)"); - return glib::ExitCode::FAILURE; - }; - let mut ok = true; - for name in ["client-known-hosts.json", "client-gtk-settings.json"] { - match std::fs::remove_file(dir.join(name)) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - eprintln!("reset: {name}: {e}"); - ok = false; - } - } - } - if ok { - println!("reset"); - glib::ExitCode::SUCCESS - } else { - glib::ExitCode::FAILURE - } -} - -/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the -/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the -/// dispatch in `app.rs` is a single line. -pub fn headless_host_command() -> Option { - if arg_flag("--list-hosts") { - return Some(headless_list_hosts()); - } - if let Some(t) = arg_value("--reachable") { - return Some(headless_reachable(&t)); - } - if let Some(t) = arg_value("--add-host") { - return Some(headless_add_host(&t)); - } - if let Some(s) = arg_value("--set-host") { - return Some(headless_set_host(&s)); - } - if let Some(s) = arg_value("--forget-host") { - return Some(headless_forget_host(&s)); - } - if arg_flag("--reset") { - return Some(headless_reset()); - } - None -} - -/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots. -pub fn shot_scene() -> Option { - std::env::var("PUNKTFUNK_SHOT_SCENE") - .ok() - .filter(|s| !s.is_empty()) -} - -/// Render one mock-populated, host-free scene over the already-presented window, then -/// print `PF_SHOT_READY` once it has settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set -/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick -/// needed. The stream and gamepad-library scenes are gone with the pages (both live in -/// the session binary now). -pub fn run_shot(ctx: &ShotCtx, scene: &str) { - let sender = &ctx.sender; - // A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars). - let mock_req = || ConnectRequest { - name: "Living Room PC".to_string(), - addr: "192.168.1.42".to_string(), - port: 9777, - fp_hex: Some( - "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(), - ), - pair_optional: true, - launch: None, - mac: Vec::new(), - profile: None, - }; - let mock_advert = - |key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost { - key: key.to_string(), - fullname: format!("{key}._punktfunk._udp.local."), - name: name.to_string(), - addr: addr.to_string(), - port: 9777, - fp_hex: fp.to_string(), - pair: "required".to_string(), - mgmt_port: None, - mac: Vec::new(), - os: "linux/arch/steamos".to_string(), - }; - - // What the self-capture renders: the main window, except for scenes that open their - // own toplevel (the shortcuts window). - let mut target: gtk::Widget = ctx.window.clone().upcast(); - let hosts = &ctx.hosts; - match scene { - // Saved hosts come from the seeded known-hosts store; on top, inject synthetic - // adverts through the same path the mDNS stream feeds. - "hosts" | "02-hosts" => { - let _ = hosts.send(HostsMsg::Advert(mock_advert( - "mock-online", - "Living Room PC", - "192.168.1.42", - "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00", - ))); - let _ = hosts.send(HostsMsg::Advert(mock_advert( - "mock-new", - "steamdeck", - "192.168.1.77", - "00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f", - ))); - } - "settings" | "03-settings" => { - // Mock devices so the shot shows the probe-dependent pickers populated. - let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice { - name: name.to_string(), - description: description.to_string(), - }; - let probes = crate::ui_settings::DeviceProbes { - adapters: vec![ - "NVIDIA GeForce RTX 4070".to_string(), - "AMD Radeon 780M".to_string(), - ], - speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")], - mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")], - }; - // `PUNKTFUNK_SHOT_SETTINGS_SCOPE=` captures the dialog in - // PROFILE scope — the second half of the settings surface (design - // client-settings-profiles.md §5.1), where only profileable rows render. - let scope = std::env::var("PUNKTFUNK_SHOT_SETTINGS_SCOPE") - .ok() - .filter(|v| !v.is_empty()) - .and_then(|reference| { - pf_client_core::profiles::ProfilesFile::load() - .resolve(&reference) - .0 - .map(|p| crate::ui_settings::Scope::Profile(p.id.clone())) - }) - .unwrap_or(crate::ui_settings::Scope::Defaults); - let dialog = crate::ui_settings::show_scoped( - &ctx.window, - ctx.settings.clone(), - &ctx.gamepad, - &probes, - scope, - |_| {}, - || {}, - ); - // Optional page for the capture (general/display/input/audio/controllers); - // the dialog opens on General otherwise. - if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") { - if !page.is_empty() { - use adw::prelude::PreferencesDialogExt as _; - dialog.set_visible_page_name(&page); - } - } - } - "trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()), - "pair" | "05-pair" => { - crate::ui_trust::pin_dialog(&ctx.window, sender, ctx.identity.clone(), mock_req()) - } - "addhost" | "06-addhost" => { - let _ = hosts.send(HostsMsg::ShowAddHost); - } - "shortcuts" | "07-shortcuts" => { - let w = crate::app::shortcuts_window(&ctx.window); - w.present(); - target = w.upcast(); - } - // The library page with injected entries: mixed stores exercising the badge set, - // no-art placeholders, and one solid-color texture standing in for a poster. - "library" | "08-library" => { - let (games, art) = mock_library(); - crate::ui_library::open_mock( - &ctx.nav, - ctx.identity.clone(), - sender, - mock_req(), - games, - art, - ); - } - other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"), - } - - let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(900); - let scene = scene.to_string(); - glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || { - use std::io::Write as _; - // Self-capture of the dialog scenes (trust/pair/settings/addhost) needs a GL - // renderer: `WidgetPaintable(window)` under the cairo software renderer doesn't - // composite the `AdwDialog` overlay layer (the dialog IS presented — the - // page-content scenes capture fine either way; CI uses GL or the X11 root-grab). - let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT") - .ok() - .filter(|p| !p.is_empty()); - if let Some(out) = &self_capture { - if let Err(e) = save_png(&target, out) { - eprintln!("PF_SHOT_ERROR scene={scene}: {e:#}"); - } - } - println!("PF_SHOT_READY scene={scene}"); - let _ = std::io::stdout().flush(); - // Self-capture mode: the shot is on disk — exit so back-to-back scene runs - // don't stack windows on a live desktop. - if self_capture.is_some() { - std::process::exit(0); - } - }); -} - -/// The mock game set for the `library` scene: mixed stores exercising the badge set, -/// plus one solid-colour poster texture. -fn mock_library() -> ( - Vec, - Vec<(String, gtk::gdk::Texture)>, -) { - let game = |id: &str, store: &str, title: &str| crate::library::GameEntry { - id: id.to_string(), - store: store.to_string(), - title: title.to_string(), - art: crate::library::Artwork::default(), - platform: None, - }; - let games = vec![ - game("steam:570", "steam", "Dota 2"), - game("steam:1091500", "steam", "Cyberpunk 2077"), - game("custom:emu-1", "custom", "RetroArch"), - game("heroic:fortnite", "heroic", "Fortnite"), - game("gog:witcher3", "gog", "The Witcher 3"), - game("lutris:osu", "lutris", "osu!"), - ]; - let art = vec![( - "steam:570".to_string(), - solid_texture(300, 450, 0x35, 0x84, 0xe4), - )]; - (games, art) -} - -/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster. -fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture { - let px = [r, g, b, 0xff].repeat((w * h) as usize); - gtk::gdk::MemoryTexture::new( - w, - h, - gtk::gdk::MemoryFormat::R8g8b8a8, - &glib::Bytes::from_owned(px), - (w * 4) as usize, - ) - .upcast() -} - -/// Snapshot `widget` (the whole window, dialogs included) into a PNG: WidgetPaintable → -/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`. -fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> { - use anyhow::Context as _; - let (w, h) = (widget.width(), widget.height()); - anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})"); - let paintable = gtk::WidgetPaintable::new(Some(widget)); - let snapshot = gtk::Snapshot::new(); - paintable.snapshot(&snapshot, f64::from(w), f64::from(h)); - let node = snapshot.to_node().context("empty snapshot")?; - let renderer = widget - .native() - .context("widget not realized")? - .renderer() - .context("no gsk renderer")?; - let texture = renderer.render_texture(node, None); - texture - .save_to_png(path) - .with_context(|| format!("save {path}"))?; - Ok(()) -} diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index f5f04a89..a1706cd9 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -1,42 +1,645 @@ -//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita). +//! `punktfunk-session` — the Vulkan session binary (punktfunk-planning +//! `linux-client-rearchitecture.md`, Phase 1: the software-path presenter MVP, which IS +//! the power-user CLI build). //! -//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the -//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the -//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`). +//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`, +//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity +//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client +//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side +//! makes the other connect silently. `--pair --connect host` runs the ceremony here, +//! with no window and no toolkit, for machines that have only a shell. +//! +//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after +//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` / +//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end, +//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed. #![forbid(unsafe_code)] -// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary. -// Root re-exports keep every `crate::trust`-style path resolving unchanged. -#[cfg(target_os = "linux")] -pub use pf_client_core::{discovery, gamepad, library, os, trust, video, wol}; +#[cfg(all(any(target_os = "linux", windows), feature = "ui"))] +mod console; -#[cfg(target_os = "linux")] -mod app; -#[cfg(target_os = "linux")] -mod cli; -// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5). -#[cfg(target_os = "linux")] -mod shortcuts; -#[cfg(target_os = "linux")] -mod spawn; -#[cfg(target_os = "linux")] -mod ui_hosts; -#[cfg(target_os = "linux")] -mod ui_library; -#[cfg(target_os = "linux")] -mod ui_settings; -#[cfg(target_os = "linux")] -mod ui_trust; +#[cfg(any(target_os = "linux", windows))] +mod session_main { + use pf_client_core::gamepad::GamepadService; + use pf_client_core::session::SessionParams; + use pf_client_core::trust; + use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; + use std::sync::atomic::AtomicBool; + use std::sync::Arc; + use std::time::Duration; -#[cfg(target_os = "linux")] -fn main() -> gtk::glib::ExitCode { - app::run() + pub const EXIT_CONNECT_FAILED: u8 = 2; + pub const EXIT_TRUST_REJECTED: u8 = 3; + pub const EXIT_PRESENTER_FAILED: u8 = 4; + + /// The value following `flag` in argv, if present (`--flag value`). + pub(crate) fn arg_value(flag: &str) -> Option { + std::env::args() + .skip_while(|a| a != flag) + .nth(1) + .filter(|v| !v.starts_with("--")) + } + + pub(crate) fn arg_flag(flag: &str) -> bool { + std::env::args().any(|a| a == flag) + } + + /// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a + /// manual launch under Gaming Mode does the right thing too. (Browse-mode only — + /// gated with `mod browse`, its one caller.) + #[cfg(feature = "ui")] + pub(crate) fn fullscreen_mode() -> bool { + arg_flag("--fullscreen") + || std::env::var_os("SteamDeck").is_some() + || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() + } + + /// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning + /// shell passes its own position so the session opens on the same monitor); absent or + /// unparsable = centered on the primary display. + pub(crate) fn window_pos() -> Option<(i32, i32)> { + let v = arg_value("--window-pos")?; + let (x, y) = v.split_once(',')?; + Some((x.trim().parse().ok()?, y.trim().parse().ok()?)) + } + + /// `--pair --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK + /// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk + /// client, a headless box, an image being provisioned. Writes the verified host into the + /// same known-hosts store `--connect` reads, so pairing here is exactly what makes the + /// later stream connect silently. + /// + /// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays + /// the desktop route) — the difference is only that this binary carries no toolkit, so it + /// is the one a minimal image installs. Present in the `--no-default-features` build too: + /// enrolment must not be the reason an embedded image has to pull in Skia. + fn headless_pair(pin: &str) -> u8 { + let Some(target) = arg_value("--connect") else { + eprintln!("--pair requires --connect host[:port]"); + return EXIT_CONNECT_FAILED; + }; + let (addr, port) = parse_host_port(&target); + // The label the HOST files this client under. A headless box has nobody to ask, so + // the hostname is the only name that will mean anything in the paired-devices list. + let name = arg_value("--name").unwrap_or_else(trust::device_name); + + let identity = match trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + eprintln!("client identity: {e:#}"); + return EXIT_CONNECT_FAILED; + } + }; + match trust::pair_with_host(&addr, port, &identity, pin, &name) { + Ok(fp) => { + let fp_hex = trust::hex(&fp); + trust::persist_host( + &arg_value("--host-label").unwrap_or_else(|| addr.clone()), + &addr, + port, + &fp_hex, + true, + ); + trust::forget_placeholder(&addr, port); + println!("paired {addr}:{port} fp={fp_hex}"); + 0 + } + Err(e) => { + eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e)); + EXIT_TRUST_REJECTED + } + } + } + + /// `host[:port]`, port defaulting to the native 9777. + pub(crate) fn parse_host_port(target: &str) -> (String, u16) { + match target.rsplit_once(':') { + Some((a, p)) => match p.parse() { + Ok(port) => (a.to_string(), port), + Err(_) => { + eprintln!("unparsable port in '{target}', using default 9777"); + (a.to_string(), 9777) + } + }, + None => (target.to_string(), 9777), + } + } + + /// `--profile ` — the settings profile this one session runs with, overriding the + /// host's own binding for this launch only (never rebinding it): the shells' "Connect + /// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's + /// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is + /// how "Connect with ▸ Default settings" reaches a bound host. + fn profile_arg() -> Option { + arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default()) + } + + /// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the + /// shell's request-access flow passes ~185 s because the host PARKS the connection + /// until the operator clicks Approve. + pub(crate) fn connect_timeout() -> Duration { + Duration::from_secs( + arg_value("--connect-timeout") + .and_then(|v| v.parse().ok()) + .unwrap_or(15), + ) + } + + /// One session's pump parameters from the EFFECTIVE settings — shared by `--connect` + /// and every `--browse` launch. Explicit settings, `0` fields resolved to the + /// window's display (the GTK client reads the monitor under its window — same + /// contract). + /// + /// `settings` is what [`trust::effective_settings`] returned, never a raw + /// `Settings::load()`: both callers resolve the host's profile first, so the two + /// construction sites cannot drift (they historically did — touching one and not the + /// other is a Windows-only build break). `profile` is that profile's name, for the + /// stats overlay's first line. + #[allow(clippy::too_many_arguments)] + pub(crate) fn session_params( + settings: &trust::Settings, + profile: Option, + clipboard_override: Option, + addr: String, + port: u16, + pin: [u8; 32], + identity: (String, String), + launch: Option, + gamepad: &GamepadService, + native: Mode, + force_software: Arc, + vulkan: Option, + ) -> SessionParams { + // Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3). In spec + // mode the spawner already resolved it; otherwise this looks it up itself, which is + // the last store read the compat path still owes. `addr` is moved into the struct + // below, so read it first. + let clipboard = clipboard_override.unwrap_or_else(|| { + trust::KnownHosts::load() + .hosts + .iter() + .any(|h| h.addr == addr && h.port == port && h.clipboard_sync) + }); + // 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 { + width: if settings.width == 0 { + native.width + } else { + settings.width + }, + height: if settings.width == 0 { + native.height + } else { + settings.height + }, + refresh_hz: if settings.refresh_hz == 0 { + native.refresh_hz.max(30) + } else { + settings.refresh_hz + }, + }; + // Render scale: multiply the resolved mode (even + codec-clamped) so the host renders + // larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the + // Native/explicit resolution so it composes uniformly with both. + let (sw, sh) = punktfunk_core::render_scale::apply( + mode.width, + mode.height, + settings.render_scale, + punktfunk_core::render_scale::max_dimension(&settings.codec), + ); + let mode = Mode { + width: sw, + height: sh, + ..mode + }; + SessionParams { + host: addr, + port, + mode, + compositor: CompositorPref::from_name(&settings.compositor) + .unwrap_or(CompositorPref::Auto), + gamepad: { + // The setting AS CHOSEN goes to the pad service too, not just the Hello: the host + // builds each virtual pad from that pad's arrival and only falls back to this + // session default for a pad that never declares one, so an explicit choice that + // stopped here would be undone the moment a controller connected. + let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto); + gamepad.set_kind_override(chosen); + match chosen { + GamepadPref::Auto => gamepad.auto_pref(), + explicit => explicit, + } + }, + bitrate_kbps: settings.bitrate_kbps, + audio_channels: settings.audio_channels, + preferred_codec: settings.preferred_codec(), + // HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades. + video_caps: if settings.hdr_enabled { + punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR + } else { + 0 + }, + // No portable Wayland/X11 display-volume query yet, so the host keeps its EDID + // defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session + // pump) pins one manually. + display_hdr: None, + // The presenter renders the host cursor locally in desktop mouse mode (M2 cursor + // channel); capture-mode sessions keep the composited cursor, so only advertise + // when the session STARTS in desktop mode. The host gates further (Linux portal + // compositors only). + cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop, + mic_enabled: settings.mic_enabled, + clipboard, + // The Settings preference (auto → VAAPI where it exists; the presenter + // demotes to software on boxes whose Vulkan can't import the dmabufs). + // PUNKTFUNK_DECODER still overrides inside the decoder for bisects. + decoder: settings.decoder.clone(), + launch, + vulkan, + pin: Some(pin), + identity, + connect_timeout: connect_timeout(), + force_software, + profile, + } + } + + /// The window's starting size under Match-window: the persisted last size, so the + /// first connect's mode already matches the glass; `None` (policy off / never + /// stored) = the 1280×720 default. + pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> { + (settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0) + .then_some((settings.last_window_w, settings.last_window_h)) + } + + /// The Match-window policy hook for the presenter loop + /// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the + /// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's + /// logical window size (load-modify-save, like the console settings screen) so the + /// next launch opens at it. + /// The Match-window policy hook (design/midstream-resolution-resize.md D1/D2). The + /// callback used to load-modify-save the shared settings file from inside the renderer — + /// one of that file's five concurrent writers, for a value only the parent needs. It now + /// REPORTS the size on stdout and the spawner persists it + /// (design/client-architecture-split.md §5). + /// + /// `persist_locally` keeps a hand-run session remembering its own window: nobody is + /// listening to stdout there, so the event alone would drop the value. A spawned session + /// leaves the write to its parent, which is the whole point. + pub(crate) fn match_window( + settings: &trust::Settings, + persist_locally: bool, + ) -> Option> { + settings.match_window.then(|| { + Box::new(move |w: u32, h: u32| { + println!("{{\"window\":{{\"w\":{w},\"h\":{h}}}}}"); + if persist_locally { + pf_client_core::orchestrate::persist_window_size(w, h); + } + }) as Box + }) + } + + /// One JSON status line on stdout (the shell parses these; strings hand-escaped via + /// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its + /// failure through the same contract when spawned with `--json-status`. + pub(crate) fn json_line(key: &str, msg: &str, trust_rejected: Option) { + let escaped: String = msg + .chars() + .flat_map(|c| match c { + '"' => vec!['\\', '"'], + '\\' => vec!['\\', '\\'], + '\n' => vec!['\\', 'n'], + c if (c as u32) < 0x20 => vec![' '], + c => vec![c], + }) + .collect(); + match trust_rejected { + Some(t) => println!("{{\"{key}\":\"{escaped}\",\"trust_rejected\":{t}}}"), + None => println!("{{\"{key}\":\"{escaped}\"}}"), + } + } + + /// Steam Deck / RADV: Mesa gates Vulkan Video decode — the `VK_KHR_video_decode_*` + /// extensions AND the decode-capable queue family — behind `RADV_PERFTEST=video_decode`. + /// Without it the presenter's device advertises no decode queue, so `Decoder::new`'s + /// `auto` path can't build the Vulkan decoder and the session silently falls back to + /// VAAPI (whose separate-plane dmabuf import shows chroma fringing — green/yellow specks + /// around the cursor — on VanGogh). We want the Vulkan path, so opt in here, before the + /// RADV driver loads (the Vulkan instance is created later, inside `run_session`). + /// + /// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video + /// decode is already the default just no-ops. Append rather than clobber so a user's own + /// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice. + #[cfg(target_os = "linux")] + fn enable_radv_video_decode() { + const TOKEN: &str = "video_decode"; + match std::env::var("RADV_PERFTEST") { + Ok(v) if v.split(',').any(|t| t == TOKEN) => return, + Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")), + _ => std::env::set_var("RADV_PERFTEST", TOKEN), + } + tracing::info!( + radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(), + "opted into RADV Vulkan Video decode (Mesa gates it behind RADV_PERFTEST on the Deck)" + ); + } + + pub fn run() -> u8 { + // Logs to STDERR — stdout is the machine interface (ready/stats/error lines). + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + // `--list-adapters`: print the Vulkan physical devices' marketing names (one per + // line, discrete first) for the desktop shells' GPU picker, then exit. + if arg_flag("--list-adapters") { + return match pf_presenter::vk::list_adapters() { + Ok(names) => { + for n in names { + println!("{n}"); + } + 0 + } + Err(e) => { + eprintln!("list-adapters: {e:#}"); + EXIT_PRESENTER_FAILED + } + }; + } + + // `--list-audio`: the PipeWire endpoints the settings pickers offer, as + // `sink|sourcenode.namedescription` lines — a debug window into the + // same enumeration the GTK shell probes. + #[cfg(target_os = "linux")] + if arg_flag("--list-audio") { + return match pf_client_core::audio::devices() { + Ok((sinks, sources)) => { + for d in sinks { + println!("sink\t{}\t{}", d.name, d.description); + } + for d in sources { + println!("source\t{}\t{}", d.name, d.description); + } + 0 + } + Err(e) => { + eprintln!("list-audio: {e:#}"); + EXIT_PRESENTER_FAILED + } + }; + } + + // `--pair `: enrol this machine against a host and exit. DEPRECATED — pairing is + // a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell + // (design/client-architecture-split.md §5). It still works, with a notice, for the one + // release this needs; a renderer owning a trust ceremony is exactly the mixing of + // concerns the split exists to undo. + if let Some(pin) = arg_value("--pair") { + eprintln!( + "note: punktfunk-session --pair is deprecated \u{2014} use `punktfunk pair \ + ` instead (same store, same result)." + ); + return headless_pair(&pin); + } + + // Before any Vulkan call: make RADV expose its video-decode queue + extensions so the + // decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV). + // Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally. + #[cfg(target_os = "linux")] + enable_radv_video_decode(); + + // The Settings device picks → env, unless the user already forced one by hand: + // the GPU (the shells' pickers store the adapter's marketing name) for the + // presenter's device selection, and the audio endpoints (PipeWire node names) + // for the playback/mic streams' `target.object`. Before any Vulkan call, like + // the RADV knob (covers --connect and --browse). + { + let s = trust::Settings::load(); + for (var, value) in [ + ("PUNKTFUNK_VK_ADAPTER", &s.adapter), + ("PUNKTFUNK_AUDIO_SINK", &s.speaker_device), + ("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device), + ] { + if std::env::var_os(var).is_none() && !value.is_empty() { + std::env::set_var(var, value); + } + } + } + + // Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming + // every pad Steam Input has virtualized; capturing the Deck's real built-in + // controller needs it cleared (same rationale as the GTK client's `app::run`). + for var in [ + "SDL_GAMECONTROLLER_IGNORE_DEVICES", + "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT", + ] { + if let Ok(v) = std::env::var(var) { + tracing::info!(var, value = %v, "clearing Steam's SDL device filter"); + std::env::remove_var(var); + } + } + + if arg_flag("--browse") { + // Bare `--browse` opens the console home (hosts, pairing, settings); + // `--browse host[:port]` opens straight into that host's library. + let target = arg_value("--browse"); + #[cfg(feature = "ui")] + return crate::console::run(target.as_deref()); + #[cfg(not(feature = "ui"))] + { + let _ = target; + eprintln!( + "--browse needs the console UI — this is the minimal build \ + (rebuild without --no-default-features)" + ); + return EXIT_PRESENTER_FAILED; + } + } + let Some(target) = arg_value("--connect") else { + eprintln!( + "usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\ + \x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\ + \x20 punktfunk-session --pair --connect host[:port] [--name LABEL]\n\ + \n\ + Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\ + gamepad console instead: bare --browse is the host list (discovery, PIN\n\ + pairing, settings, wake-on-LAN); with a target it opens that host's game\n\ + library. --profile picks a settings profile by id or name for this session\n\ + only (\"\" = the global defaults); without it the host's own profile applies.\n\ + --connect never dials a host it has no pinned fingerprint for —\n\ + enrol with --pair (no display needed), in the console, or from the desktop\n\ + client." + ); + return EXIT_CONNECT_FAILED; + }; + let (addr, port) = parse_host_port(&target); + + let identity = match trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + json_line("error", &format!("client identity: {e:#}"), None); + return EXIT_CONNECT_FAILED; + } + }; + // `--resolved-spec `: the spawner already did the resolving, so this process + // performs ZERO store reads (design/client-architecture-split.md §5) — no Settings + // load, no known-hosts lookup, no profile resolution. Without it (a hand-run + // `--connect`, an old Decky script) the session resolves for itself through the SAME + // helper, so the two modes cannot drift. + let spec = arg_value("--resolved-spec").map(std::path::PathBuf::from); + let (settings, profile_name, clipboard_override) = match &spec { + Some(path) => match pf_client_core::orchestrate::ResolvedSpec::read(path) { + Ok(s) => { + tracing::info!(path = %path.display(), "running from a resolved spec"); + (s.settings, s.profile, Some(s.clipboard)) + } + Err(e) => { + json_line("error", &format!("resolved spec: {e}"), None); + return EXIT_CONNECT_FAILED; + } + }, + None => { + let (settings, profile) = + trust::effective_settings(&addr, port, profile_arg().as_deref()); + (settings, profile.map(|p| p.name), None) + } + }; + if let Some(name) = &profile_name { + tracing::info!(profile = %name, "streaming with a settings profile"); + } + + // Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin + // connects silently; an unknown host is REFUSED — there is no dialog here, and a + // silent TOFU would defeat the pinning model. Pair via the desktop client. + let known = trust::KnownHosts::load(); + let known_host = known + .hosts + .iter() + .find(|h| h.addr == addr && h.port == port); + let pin = arg_value("--fp") + .as_deref() + .and_then(trust::parse_hex32) + .or_else(|| known_host.and_then(|h| trust::parse_hex32(&h.fp_hex))); + let Some(pin) = pin else { + json_line( + "error", + &format!( + "no pinned fingerprint for {addr}:{port} — pair first \ + (punktfunk-session --pair --connect {addr}:{port}) or pass --fp HEX" + ), + Some(true), + ); + return EXIT_TRUST_REJECTED; + }; + + let host_label = known_host.map_or_else(|| addr.clone(), |h| h.name.clone()); + let launch = arg_value("--launch"); + let title = launch + .clone() + .map_or_else(|| host_label.clone(), |id| format!("{host_label} · {id}")); + + let fullscreen = arg_flag("--fullscreen") + || std::env::var_os("SteamDeck").is_some() + || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some(); + + let opts = pf_presenter::SessionOpts { + window_title: format!("Punktfunk · {title}"), + fullscreen, + window_pos: window_pos(), + // `--stats` forces the overlay visible (tooling/debug runs) without + // demoting an explicitly chosen richer tier. + stats_verbosity: match settings.stats_verbosity() { + trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal, + v => v, + }, + touch_mode: settings.touch_mode(), + mouse_mode: settings.mouse_mode(), + invert_scroll: settings.invert_scroll, + json_status: true, + on_connected: Some(Box::new(|fingerprint: [u8; 32]| { + // This host's card carries the accent bar in the desktop client now. + trust::touch_last_used(&trust::hex(&fingerprint)); + })), + // The Skia console UI (stats OSD, capture HUD) — compiled out of the + // power-user build (`--no-default-features` drops the `ui` feature). + #[cfg(feature = "ui")] + overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())), + #[cfg(not(feature = "ui"))] + overlay: None, + window_size: window_size(&settings), + // A spawned session (spec mode) reports its window; a hand-run one persists it. + match_window: match_window(&settings, spec.is_none()), + render_scale: settings.render_scale, + render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec), + }; + + let outcome = + pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| { + session_params( + &settings, + profile_name, + clipboard_override, + addr, + port, + pin, + identity, + launch, + gamepad, + native, + force_software, + vulkan, + ) + }); + + match outcome { + Ok(pf_presenter::Outcome::Ended(None)) => 0, + Ok(pf_presenter::Outcome::Ended(Some(reason))) => { + // The host ending the session (game quit, host shutdown) is a normal end + // for a one-shot stream binary — report the reason, exit clean. + json_line("ended", &reason, None); + 0 + } + Ok(pf_presenter::Outcome::ConnectFailed { + msg, + trust_rejected, + }) => { + json_line("error", &msg, Some(trust_rejected)); + if trust_rejected { + EXIT_TRUST_REJECTED + } else { + EXIT_CONNECT_FAILED + } + } + Err(e) => { + json_line("error", &format!("presenter: {e:#}"), None); + EXIT_PRESENTER_FAILED + } + } + } } -/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS -/// (the Mac client lives in clients/apple). -#[cfg(not(target_os = "linux"))] +#[cfg(any(target_os = "linux", windows))] +fn main() -> std::process::ExitCode { + std::process::ExitCode::from(session_main::run()) +} + +/// This stub keeps `cargo build --workspace` green elsewhere (the Mac client lives in +/// clients/apple). +#[cfg(not(any(target_os = "linux", windows)))] fn main() { - eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple"); + eprintln!( + "punktfunk-session runs on Linux and Windows — the macOS client lives in clients/apple" + ); std::process::exit(2); } diff --git a/clients/session/src/shortcuts.rs b/clients/session/src/shortcuts.rs deleted file mode 100644 index ab856827..00000000 --- a/clients/session/src/shortcuts.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! "Create shortcut…" — a desktop entry that boots straight into one host (optionally with a -//! profile or a game), design/client-deep-links.md §5. -//! -//! The shortcut is a **container for a URL**, not a second launch mechanism: it invokes the -//! client with a positional `punktfunk://…`, which is the same door xdg-open and a browser -//! prompt use. That is deliberate — it keeps working if scheme registration is lost or the -//! host store changes, because the URL itself carries the stable id, the address and the pin. -//! -//! Under flatpak the sandbox cannot write `~/.local/share/applications`, so this offers the -//! URL to copy instead. The `org.freedesktop.portal.DynamicLauncher` route (which exists for -//! exactly this, with its own confirmation) is the intended upgrade there. - -use std::path::PathBuf; - -/// Are we inside the flatpak sandbox? `/.flatpak-info` is present in every flatpak run and -/// nowhere else — the standard check, and the one the portal docs use. -pub fn sandboxed() -> bool { - std::path::Path::new("/.flatpak-info").exists() -} - -/// Write `~/.local/share/applications/punktfunk-.desktop` for this URL and return the -/// path. Best-effort `update-desktop-database` afterwards: an entry nothing indexes still -/// works from a file manager, it just won't show up in search straight away. -pub fn write_desktop_entry(label: &str, url: &str) -> Result { - let home = std::env::var("HOME").map_err(|_| "HOME isn't set".to_string())?; - let dir = PathBuf::from(home).join(".local/share/applications"); - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let path = dir.join(format!("punktfunk-{}.desktop", file_slug(label))); - // Desktop-entry values are line-oriented: a newline in a host name would end the Name - // key and turn the rest into an unparsable line (or, worse, another key). - let name = one_line(label); - let entry = format!( - "[Desktop Entry]\n\ - Type=Application\n\ - Name={name}\n\ - Comment=Stream from this Punktfunk host\n\ - Exec=punktfunk-client \"{url}\"\n\ - Icon=io.unom.Punktfunk\n\ - Terminal=false\n\ - Categories=Game;Network;\n\ - StartupNotify=true\n" - ); - std::fs::write(&path, entry).map_err(|e| format!("{}: {e}", path.display()))?; - // Some desktops only offer a `.desktop` as a launchable icon when it is executable. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)); - } - let _ = std::process::Command::new("update-desktop-database") - .arg(&dir) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - Ok(path) -} - -/// A filename-safe slug: ASCII alphanumerics and `-`, everything else collapsed to one `-`, -/// capped so a long host+profile pair can't produce a name the filesystem rejects. -fn file_slug(label: &str) -> String { - let mut out = String::new(); - for c in label.chars() { - if c.is_ascii_alphanumeric() { - out.push(c.to_ascii_lowercase()); - } else if !out.ends_with('-') { - out.push('-'); - } - } - let trimmed = out.trim_matches('-'); - let capped: String = trimmed.chars().take(48).collect(); - if capped.is_empty() { - "host".to_string() - } else { - capped - } -} - -/// Flatten to one line — see [`write_desktop_entry`] on why a newline here is not cosmetic. -fn one_line(s: &str) -> String { - s.replace(['\n', '\r'], " ").trim().to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Names become safe filenames, and a name that survives to `Name=` can't break the file. - #[test] - fn labels_are_sanitised_both_ways() { - assert_eq!(file_slug("Living Room PC"), "living-room-pc"); - assert_eq!(file_slug("Büro · Mac"), "b-ro-mac"); - assert_eq!(file_slug("Desk · Work"), "desk-work"); - assert_eq!(file_slug("////"), "host"); - assert_eq!(file_slug(""), "host"); - assert!(file_slug(&"x".repeat(200)).len() <= 48); - // The classic injection: a newline would end the Name key and start a new one. - assert_eq!( - one_line("Desk\nExec=rm -rf ~"), - "Desk Exec=rm -rf ~".to_string() - ); - } -} diff --git a/clients/session/src/spawn.rs b/clients/session/src/spawn.rs deleted file mode 100644 index 3530745f..00000000 --- a/clients/session/src/spawn.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session` -//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of -//! punktfunk-planning `linux-client-rearchitecture.md`). What is left here is the -//! TRANSLATION: a [`ConnectRequest`] becomes a `ConnectPlan`, and the session's typed -//! lifecycle events become the [`AppMsg`]s the relm4 app consumes — spinner until -//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, exit code 3 + -//! `trust_rejected` routed to the re-pair PIN ceremony. -//! -//! Spawning, the argv, the stdout contract and the child handle live in -//! `pf_client_core::orchestrate` (design/client-architecture-split.md §3) — the WinUI shell -//! and the coming CLI spawn through the same code, so "what flags does a stream get" and -//! "what does ready mean" have exactly one answer. - -use crate::app::AppMsg; -use crate::ui_hosts::ConnectRequest; -use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent}; -use pf_client_core::trust::Settings; - -/// Spawn tunables beyond a plain connect. -#[derive(Debug, Default)] -pub struct SpawnOpts { - /// Handshake budget override (`--connect-timeout`) — the request-access flow passes - /// ~185 s because the host PARKS the connection until the operator approves. - pub connect_timeout_secs: Option, - /// Persist the host as *paired* once the child reports ready (request-access: the - /// operator's approval IS the pairing). Plain TOFU persists unpaired. - pub persist_paired: bool, - /// A cancel handle to arm (request-access's waiting dialog): killing the child is - /// the only abort a parked connect has. - pub cancel: Option, -} - -pub use orchestrate::{session_binary, CancelHandle}; - -/// Spawn the session binary for a connect with `fp_hex` pinned and translate its -/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert -/// rather than the store — the app persists it once the child reports ready (the child -/// connects pinned to it, so ready proves the host really holds that identity). -/// -/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` = -/// the spawn itself failed (binary missing?) — surfaced as a connect error. -pub fn spawn_session( - sender: relm4::Sender, - req: ConnectRequest, - fp_hex: String, - tofu: bool, - fullscreen_on_stream: bool, - opts: SpawnOpts, -) -> Result<(), String> { - // The plan this connect resolves to. A plain card click carries no `--profile`: it honors - // the host's own binding, which the session resolves through the same helper this shell - // would have used (design/client-settings-profiles.md §4.6) — passing it here would be a - // second source of truth for one decision. Only a "Connect with ▸" pick (or a URL's - // `profile=`) sets one, and it applies to this session alone. - // - // Two fields are deliberately not the shell's state yet, because nothing in the spawn path - // reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake` - // is false because this shell still runs its own dial-first wake fallback in `app.rs`. Both - // become real when the GTK connect path moves onto `ConnectOrchestrator` (arch-split C0). - let plan = ConnectPlan { - host: HostTarget { - name: req.name.clone(), - addr: req.addr.clone(), - port: req.port, - fp_hex: Some(fp_hex.clone()), - mac: req.mac.clone(), - id: None, - }, - launch: req.launch.as_ref().map(|(id, _)| id.clone()), - profile: None, - // A one-off pick rides the flag; without one the session resolves the host's own - // binding through the same helper this shell would have used. - profile_override: req.profile.clone(), - settings: Settings { - fullscreen_on_stream, - ..Default::default() - }, - wake: false, - connect_timeout_secs: opts.connect_timeout_secs, - tofu, - // The per-host clipboard decision, resolved here so the child doesn't look it up - // again — matched by address, the way every other per-host lookup matches. - clipboard: pf_client_core::trust::KnownHosts::load() - .hosts - .iter() - .any(|h| h.addr == req.addr && h.port == req.port && h.clipboard_sync), - }; - - let persist_paired = opts.persist_paired; - let (mut error, mut ended) = (None::<(String, bool)>, None::); - orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev { - SessionEvent::Ready => { - let _ = sender.send(AppMsg::SessionReady { - req: req.clone(), - fp_hex: fp_hex.clone(), - tofu, - persist_paired, - }); - } - SessionEvent::Error { - msg, - trust_rejected, - } => error = Some((msg, trust_rejected)), - SessionEvent::Ended(msg) => ended = Some(msg), - // The brain persists the window size; the shell has nothing to do with it. - SessionEvent::Window { .. } => {} - SessionEvent::Exited(code) => { - let _ = sender.send(AppMsg::SessionExited { - req: req.clone(), - code, - error: error.take(), - ended: ended.take(), - tofu, - }); - } - })?; - Ok(()) -} diff --git a/clients/session/src/ui_hosts.rs b/clients/session/src/ui_hosts.rs deleted file mode 100644 index 6ac134ff..00000000 --- a/clients/session/src/ui_hosts.rs +++ /dev/null @@ -1,1402 +0,0 @@ -//! The hosts page as a relm4 component: adaptive card grids for saved (trusted/paired) -//! and mDNS-discovered hosts — avatar + name + `addr:port` + status pills, online pips, -//! dashed discovered cards, an overflow menu, an add-host dialog, and a connect-failure -//! banner. Cards are a [`FactoryVecDeque`]; both grids re-populate from one state -//! snapshot (known hosts on disk + the live advert map) on every change, so dedup and -//! the online pips stay consistent. Actions leave as typed [`HostsOutput`]s — the -//! callback bag and `Rc>` pokes of the pre-relm4 shell are gone. - -use crate::discovery::{self, DiscoveredHost, DiscoveryEvent}; -use crate::trust::{KnownHost, KnownHosts, Settings}; -use adw::prelude::*; -use gtk::{gio, glib}; -use relm4::factory::FactoryVecDeque; -use relm4::prelude::*; -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; - -/// What the user asked to connect to. `fp_hex` comes from the mDNS TXT record when the -/// host was discovered (drives the trust decision *before* connecting); manual entries -/// have none. `pair_optional` is true ONLY when a discovered host advertised -/// `pair=optional` — the sole case in which the reduced-security TOFU path may be -/// offered; every other case mandates PIN pairing. -#[derive(Clone, Debug)] -pub struct ConnectRequest { - pub name: String, - pub addr: String, - pub port: u16, - pub fp_hex: Option, - pub pair_optional: bool, - /// A library title to launch on connect (`(library id, display name)`). - pub launch: Option<(String, String)>, - /// Wake-on-LAN MAC(s) for this host. Empty when none is known. - pub mac: Vec, - /// A ONE-OFF settings profile for this connect ("Connect with ▸ X"): `Some(id)` overrides - /// the host's binding for this launch, `Some("")` forces the global defaults on a bound - /// host, `None` honors the binding. It never rebinds anything — the host's default changes - /// only through an explicit "Default profile" pick (design/client-settings-profiles.md §5.2). - pub profile: Option, -} - -impl ConnectRequest { - /// The key the page tracks an in-flight connect under (the card that swaps its - /// avatar for a spinner): the fingerprint when known, else the address. - pub fn card_key(&self) -> String { - self.fp_hex - .clone() - .unwrap_or_else(|| format!("{}:{}", self.addr, self.port)) - } -} - -// --- The card factory --------------------------------------------------------------------- - -/// One card's full render input — rebuilt (clear + repopulate) on every state change, -/// exactly like the pre-relm4 full-grid rebuild (a handful of widgets; simpler than row -/// surgery and keeps every derived view consistent). -#[derive(Debug)] -pub struct HostCard { - kind: CardKind, - connecting: bool, -} - -#[derive(Debug)] -enum CardKind { - Saved { - host: KnownHost, - online: bool, - recent: bool, - library_enabled: bool, - /// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per - /// refresh rather than re-read per card. - profiles: Rc>, - /// `Some((id, name))` when this card is a PINNED host+profile pair rather than the - /// host's primary card (design §5.2a): a one-click shortcut for a profile the user - /// reaches for often. It is presentation state on the host record — never a second - /// host entry, which would fork pairing, WoL and renames. - pinned: Option<(String, String)>, - }, - Discovered(DiscoveredHost), -} - -#[derive(Debug)] -pub enum CardOutput { - Connect(ConnectRequest), - /// Set (or clear, with `None`) this host's DEFAULT settings profile — the explicit - /// rebinding act; a one-off connect never does this. - BindProfile { - fp_hex: String, - addr: String, - port: u16, - profile_id: Option, - }, - WakeConnect(ConnectRequest), - Pair(ConnectRequest), - SpeedTest(ConnectRequest), - Library(ConnectRequest), - /// Open the host edit sheet (name, profile binding, clipboard). - Edit { - fp_hex: String, - name: String, - }, - Forget { - fp_hex: String, - name: String, - }, - Wake { - mac: Vec, - addr: String, - }, - /// Put this card's `punktfunk://` URL on the clipboard. - CopyLink(String), - /// Write a desktop entry that launches this card's URL. - CreateShortcut { - label: String, - url: String, - }, - /// Add or remove a pinned host+profile card (design §5.2a). Presentation only — it never - /// changes the host's default profile, and unpinning never touches the profile itself. - TogglePin { - fp_hex: String, - addr: String, - port: u16, - profile_id: String, - pin: bool, - }, -} - -impl HostCard { - fn request(&self) -> ConnectRequest { - match &self.kind { - CardKind::Saved { - host: k, pinned, .. - } => ConnectRequest { - name: k.name.clone(), - addr: k.addr.clone(), - port: k.port, - fp_hex: Some(k.fp_hex.clone()), - // Saved host: its fp is already pinned → silent pinned connect. - pair_optional: false, - launch: None, - mac: k.mac.clone(), - // A pinned card IS its profile: clicking it connects with that one, without - // touching the host's default. - profile: pinned.as_ref().map(|(id, _)| id.clone()), - }, - CardKind::Discovered(a) => ConnectRequest { - name: a.name.clone(), - addr: a.addr.clone(), - port: a.port, - fp_hex: (!a.fp_hex.is_empty()).then(|| a.fp_hex.clone()), - // TOFU only when the host explicitly opts in with pair=optional. - pair_optional: a.pair == "optional", - launch: None, - mac: a.mac.clone(), - profile: None, - }, - } - } -} - -impl relm4::factory::FactoryComponent for HostCard { - type Init = HostCard; - type Input = (); - type Output = CardOutput; - type CommandOutput = (); - type ParentWidget = gtk::FlowBox; - type Root = gtk::Overlay; - type Widgets = (); - type Index = relm4::factory::DynamicIndex; - - fn init_model( - init: Self::Init, - _index: &Self::Index, - _sender: relm4::FactorySender, - ) -> Self { - init - } - - fn init_root(&self) -> Self::Root { - gtk::Overlay::new() - } - - fn init_widgets( - &mut self, - _index: &Self::Index, - overlay: Self::Root, - returned: >k::FlowBoxChild, - sender: relm4::FactorySender, - ) -> Self::Widgets { - let req = self.request(); - - // The shared scaffold: avatar (spinner while connecting) / name / addr / status. - let content = gtk::Box::new(gtk::Orientation::Vertical, 6); - if self.connecting { - let spinner = gtk::Spinner::new(); - spinner.set_size_request(48, 48); - spinner.start(); - spinner.set_halign(gtk::Align::Center); - content.append(&spinner); - } else { - let avatar = adw::Avatar::new(48, Some(&req.name), true); - avatar.set_halign(gtk::Align::Center); - content.append(&avatar); - } - let name_label = gtk::Label::new(Some(&req.name)); - name_label.add_css_class("heading"); - name_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); - content.append(&name_label); - let addr_label = gtk::Label::new(Some(&format!("{}:{}", req.addr, req.port))); - addr_label.add_css_class("caption"); - addr_label.add_css_class("dim-label"); - addr_label.add_css_class("numeric"); - addr_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); - content.append(&addr_label); - - let status = gtk::Box::new(gtk::Orientation::Horizontal, 6); - status.set_halign(gtk::Align::Center); - status.set_margin_top(4); - // The host's OS mark leads the row; nothing at all for an older host that doesn't - // advertise one, so those cards render exactly as they always did. - let os_chain = match &self.kind { - CardKind::Saved { host: k, .. } => k.os.as_str(), - CardKind::Discovered(a) => a.os.as_str(), - }; - if let Some(img) = os_icon_image(os_chain) { - status.append(&img); - } - let pill = |text: &str, class: &str| { - let l = gtk::Label::new(Some(text)); - l.add_css_class("pf-pill"); - l.add_css_class(class); - l - }; - match &self.kind { - CardKind::Saved { - host: k, - online, - profiles, - pinned, - .. - } => { - // Presence pip + spelled-out state, then the trust pill. - let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0); - pip.add_css_class("pf-pip"); - if *online { - pip.add_css_class("pf-online"); - } - pip.set_valign(gtk::Align::Center); - status.append(&pip); - let presence = gtk::Label::new(Some(if *online { "Online" } else { "Offline" })); - presence.add_css_class("caption"); - presence.add_css_class("dim-label"); - status.append(&presence); - status.append(&if k.paired { - pill("Paired", "pf-green") - } else { - pill("Trusted", "pf-accent") - }); - // The chip says what a plain click on THIS card will do: its own profile on a - // pinned card, the host's binding on the primary one. A binding whose profile - // was deleted shows nothing and resolves as the defaults, which is exactly - // what will happen on connect (design §6). - let chip = pinned.as_ref().map(|(_, name)| name.as_str()).or_else(|| { - k.profile_id - .as_ref() - .and_then(|id| profiles.iter().find(|(pid, _)| pid == id)) - .map(|(_, name)| name.as_str()) - }); - if let Some(name) = chip { - status.append(&pill( - name, - if pinned.is_some() { - "pf-accent" - } else { - "pf-neutral" - }, - )); - } - } - CardKind::Discovered(_) => { - status.append(&if req.pair_optional { - pill("Open", "pf-neutral") - } else { - pill("PIN", "pf-accent") - }); - } - } - content.append(&status); - - overlay.set_child(Some(&content)); - overlay.add_css_class("card"); - overlay.add_css_class("pf-host-card"); - if self.connecting { - returned.set_sensitive(false); - } - - match &self.kind { - CardKind::Saved { - host: k, - online, - recent, - library_enabled, - profiles, - pinned, - } => { - if *recent { - overlay.add_css_class("pf-recent"); - } - // Overflow menu (top-right; also on right-click). - let actions = gio::SimpleActionGroup::new(); - let add = |name: &str, out: Box CardOutput>| { - let a = gio::SimpleAction::new(name, None); - let sender = sender.clone(); - a.connect_activate(move |_, _| { - let _ = sender.output(out()); - }); - actions.add_action(&a); - }; - { - let req = req.clone(); - add("pair", Box::new(move || CardOutput::Pair(req.clone()))); - } - { - let req = req.clone(); - add( - "speed", - Box::new(move || CardOutput::SpeedTest(req.clone())), - ); - } - { - let req = req.clone(); - add( - "library", - Box::new(move || CardOutput::Library(req.clone())), - ); - } - { - let (fp, name) = (k.fp_hex.clone(), k.name.clone()); - add( - "rename", - Box::new(move || CardOutput::Edit { - fp_hex: fp.clone(), - name: name.clone(), - }), - ); - } - { - let (fp, name) = (k.fp_hex.clone(), k.name.clone()); - add( - "forget", - Box::new(move || CardOutput::Forget { - fp_hex: fp.clone(), - name: name.clone(), - }), - ); - } - { - let (mac, addr) = (k.mac.clone(), k.addr.clone()); - add( - "wake", - Box::new(move || CardOutput::Wake { - mac: mac.clone(), - addr: addr.clone(), - }), - ); - } - // Profiles: a ONE-OFF connect ("Connect with"), and the explicit rebinding - // act ("Default profile"). They are separate menus on purpose — the whole - // predictability rule is that connecting with a profile never changes what - // the card will do next time (design §5.2). - { - let profile_action = - |name: &str, out: Box) -> CardOutput>| { - let a = gio::SimpleAction::new(name, Some(glib::VariantTy::STRING)); - let sender = sender.clone(); - a.connect_activate(move |_, param| { - // The empty string is "Default settings" — a real choice, not - // an absent one, so it has to survive as a value. - let id = param.and_then(|p| p.str()).unwrap_or("").to_string(); - let _ = sender.output(out(Some(id).filter(|s| !s.is_empty()))); - }); - actions.add_action(&a); - }; - let req_for_connect = req.clone(); - profile_action( - "connect-with", - Box::new(move |id| { - let mut req = req_for_connect.clone(); - // `Some("")` — not `None` — so a bound host really does connect - // with the defaults when the user asks for them. - req.profile = Some(id.unwrap_or_default()); - CardOutput::Connect(req) - }), - ); - let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port); - profile_action( - "bind-profile", - Box::new(move |id| CardOutput::BindProfile { - fp_hex: fp.clone(), - addr: addr.clone(), - port, - profile_id: id, - }), - ); - // "Copy link": the self-emitted URL for this card, which is the pairing - // an external tool (a Playnite entry, a Stream Deck macro) is configured - // with. It carries the stable id AND host+fp, so it still resolves after a - // re-address or a reinstall (design/client-deep-links.md §2/§5). - { - let (host, profile) = (k.clone(), pinned.clone()); - let a = gio::SimpleAction::new("copy-link", None); - let sender = sender.clone(); - a.connect_activate(move |_, _| { - let url = pf_client_core::deeplink::DeepLink::for_host( - &host, - None, - profile.as_ref().map(|(id, _)| id.as_str()), - ) - .to_url(); - let _ = sender.output(CardOutput::CopyLink(url)); - }); - actions.add_action(&a); - } - // "Create shortcut…": the same URL as Copy link, wrapped in a desktop - // entry so it is double-clickable from the app grid. - { - let (host, profile) = (k.clone(), pinned.clone()); - let a = gio::SimpleAction::new("shortcut", None); - let sender = sender.clone(); - a.connect_activate(move |_, _| { - let url = pf_client_core::deeplink::DeepLink::for_host( - &host, - None, - profile.as_ref().map(|(id, _)| id.as_str()), - ) - .to_url(); - let label = match &profile { - Some((_, name)) => format!("{} \u{00b7} {name}", host.name), - None => host.name.clone(), - }; - let _ = sender.output(CardOutput::CreateShortcut { label, url }); - }); - actions.add_action(&a); - } - // The same action pins from a primary card and unpins from a pinned one — - // which of the two this card is decides the direction. - let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port); - let pinning = pinned.is_none(); - profile_action( - "toggle-pin", - Box::new(move |id| CardOutput::TogglePin { - fp_hex: fp.clone(), - addr: addr.clone(), - port, - profile_id: id.unwrap_or_default(), - pin: pinning, - }), - ); - } - overlay.insert_action_group("card", Some(&actions)); - - let menu = gio::Menu::new(); - if let Some((pin_id, pin_name)) = pinned { - // A pinned card is a shortcut, not a second host: it offers the one-offs - // and its own removal, and deliberately NOT pair/rename/forget — those - // belong to the host, and offering them here would blur what the card is. - let with = gio::Menu::new(); - for (label, target) in std::iter::once(("Default settings", "")).chain( - profiles - .iter() - .map(|(id, name)| (name.as_str(), id.as_str())), - ) { - let item = gio::MenuItem::new(Some(label), None); - item.set_action_and_target_value( - Some("card.connect-with"), - Some(&target.to_variant()), - ); - with.append_item(&item); - } - menu.append_submenu(Some("Connect with"), &with); - let unpin = gio::MenuItem::new( - Some(&format!("Unpin \u{201c}{pin_name}\u{201d}")), - None, - ); - unpin.set_action_and_target_value( - Some("card.toggle-pin"), - Some(&pin_id.as_str().to_variant()), - ); - menu.append_item(&unpin); - menu.append(Some("Copy link"), Some("card.copy-link")); - menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut")); - } else { - if !profiles.is_empty() { - let with = gio::Menu::new(); - let bind = gio::Menu::new(); - let pin = gio::Menu::new(); - for (label, id) in std::iter::once(("Default settings", "")).chain( - profiles - .iter() - .map(|(id, name)| (name.as_str(), id.as_str())), - ) { - // A checkmark would be the natural cue for the current binding, but - // a GMenu radio needs shared state per card; the bound profile is - // named on the card's chip instead, visible without opening a menu. - for (menu, action) in - [(&with, "card.connect-with"), (&bind, "card.bind-profile")] - { - let item = gio::MenuItem::new(Some(label), None); - item.set_action_and_target_value( - Some(action), - Some(&id.to_variant()), - ); - menu.append_item(&item); - } - // "Default settings" is not pinnable — the primary card is that. - if !id.is_empty() && !k.pinned_profiles.iter().any(|p| p == id) { - let item = gio::MenuItem::new(Some(label), None); - item.set_action_and_target_value( - Some("card.toggle-pin"), - Some(&id.to_variant()), - ); - pin.append_item(&item); - } - } - menu.append_submenu(Some("Connect with"), &with); - menu.append_submenu(Some("Default profile"), &bind); - if pin.n_items() > 0 { - menu.append_submenu(Some("Pin as card"), &pin); - } - } - menu.append(Some("Pair with PIN\u{2026}"), Some("card.pair")); - menu.append(Some("Test network speed\u{2026}"), Some("card.speed")); - // An explicit wake only when offline and a MAC is known. - if !online && !k.mac.is_empty() { - menu.append(Some("Wake host"), Some("card.wake")); - } - // Experimental (Preferences gate): browse the host's game library. - if *library_enabled { - menu.append(Some("Browse library\u{2026}"), Some("card.library")); - } - menu.append(Some("Copy link"), Some("card.copy-link")); - menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut")); - menu.append(Some("Edit\u{2026}"), Some("card.rename")); - menu.append(Some("Forget"), Some("card.forget")); - } - let menu_btn = gtk::MenuButton::builder() - .icon_name("view-more-symbolic") - .menu_model(&menu) - .halign(gtk::Align::End) - .valign(gtk::Align::Start) - .build(); - menu_btn.add_css_class("flat"); - overlay.add_overlay(&menu_btn); - let right_click = gtk::GestureClick::builder().button(3).build(); - { - let menu_btn = menu_btn.clone(); - right_click.connect_pressed(move |_, _, _, _| menu_btn.popup()); - } - overlay.add_controller(right_click); - - // Auto-wake: not advertising + a known MAC routes to WakeConnect, which - // dials first (a routed/Tailscale host is mDNS-blind, not asleep) and only - // falls into the wake-and-wait when the dial fails. - let wake_first = !online && !req.mac.is_empty(); - let sender = sender.clone(); - returned.connect_activate(move |_| { - let _ = sender.output(if wake_first { - CardOutput::WakeConnect(req.clone()) - } else { - CardOutput::Connect(req.clone()) - }); - }); - } - CardKind::Discovered(_) => { - overlay.add_css_class("pf-discovered"); - // Tap-to-connect only (parity with Android's discovered cards). - let sender = sender.clone(); - returned.connect_activate(move |_| { - let _ = sender.output(CardOutput::Connect(req.clone())); - }); - } - } - } -} - -// --- The page component --------------------------------------------------------------------- - -/// How long each saved-host reachability probe waits, and how often the sweep runs. The pip -/// reads `advertising OR probed-reachable`, so a host reached only over a routed network -/// (Tailscale/VPN) — which never appears on mDNS — still shows Online. -/// The OS-icon tokens this shell ships symbolic art for (`data/icons/.../pf-os--symbolic.svg`, -/// embedded via gresource). Chains walk most-specific-first, so a distro without its own mark -/// (Bazzite, CachyOS, ...) lands on its family's and finally on plain Tux. -const OS_ICON_TOKENS: &[&str] = &[ - "windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos", "opensuse", -]; - -/// The card's OS glyph for an advertised chain, or `None` (no widget) when the host doesn't -/// advertise one / nothing in the chain is recognized-and-drawable. Symbolic, so it recolors -/// with the Adwaita theme like every other status glyph; the raw chain is the tooltip. -fn os_icon_image(chain: &str) -> Option { - let token = crate::os::os_icon_tokens(chain) - .into_iter() - .find(|t| OS_ICON_TOKENS.contains(&t.as_str()))?; - let img = gtk::Image::from_icon_name(&format!("pf-os-{token}-symbolic")); - img.set_pixel_size(14); - img.add_css_class("dim-label"); - img.set_valign(gtk::Align::Center); - img.set_tooltip_text(Some(chain)); - Some(img) -} - -const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2500); -const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(12); - -/// The key a saved host is tracked under in the probe-results map: its fingerprint (stable -/// across IP changes) when it has one, else `addr:port` (a not-yet-paired manual entry). -fn saved_key(h: &KnownHost) -> String { - if h.fp_hex.is_empty() { - format!("{}:{}", h.addr, h.port) - } else { - h.fp_hex.clone() - } -} - -pub struct HostsPage { - adverts: HashMap, - /// Saved hosts proven reachable by the periodic QUIC probe (mDNS-independent), keyed by - /// [`saved_key`]. OR'd with live-advert presence to drive the Online pip. - probed: HashMap, - connecting: Option, - settings: Rc>, - saved: FactoryVecDeque, - discovered: FactoryVecDeque, - widgets: PageWidgets, -} - -struct PageWidgets { - stack: gtk::Stack, - banner: adw::Banner, - saved_heading: gtk::Label, - disc_heading: gtk::Label, - searching: gtk::Box, -} - -#[derive(Debug)] -pub enum HostsMsg { - /// A resolved mDNS advert (also the CI scenes' injection path). - Advert(DiscoveredHost), - AdvertRemoved { - fullname: String, - }, - /// Reload the disk store and re-render (fresh pairings, renames, the library gate). - Refresh, - /// A completed reachability sweep: saved-host key → reachable. Merged into the online pips. - Probed(HashMap), - /// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores. - SetConnecting(Option), - ShowError(String), - ClearError, - ShowAddHost, - /// Forwarded card actions (factory outputs). - Card(CardOutput), -} - -#[derive(Debug)] -pub enum HostsOutput { - Connect(ConnectRequest), - /// A one-line confirmation for the window's toast overlay. - Toast(String), - WakeConnect(ConnectRequest), - Pair(ConnectRequest), - SpeedTest(ConnectRequest), - /// With the advertised mgmt port when a live advert carries one. - Library(ConnectRequest, Option), -} - -impl SimpleComponent for HostsPage { - type Init = Rc>; - type Input = HostsMsg; - type Output = HostsOutput; - type Root = adw::NavigationPage; - type Widgets = (); - - fn init_root() -> Self::Root { - adw::NavigationPage::builder() - .title("Punktfunk") - .tag("hosts") - .build() - } - - fn init( - settings: Self::Init, - page: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let make_flow = || { - let f = gtk::FlowBox::builder() - .selection_mode(gtk::SelectionMode::None) - .activate_on_single_click(true) - .homogeneous(true) - .min_children_per_line(1) - .max_children_per_line(4) - .column_spacing(12) - .row_spacing(12) - .build(); - // Scopes the concentric hover-highlight radius (see app.rs CSS). - f.add_css_class("pf-host-grid"); - f - }; - let heading = |text: &str| { - let l = gtk::Label::new(Some(text)); - l.add_css_class("heading"); - l.set_halign(gtk::Align::Start); - l - }; - let saved_heading = heading("Saved hosts"); - let disc_heading = heading("On this network"); - - let saved = FactoryVecDeque::::builder() - .launch(make_flow()) - .forward(sender.input_sender(), HostsMsg::Card); - let discovered = FactoryVecDeque::::builder() - .launch(make_flow()) - .forward(sender.input_sender(), HostsMsg::Card); - - // A pointer click (and keyboard activate) emits `child-activated` on the - // *FlowBox*, never the child's own `activate` signal — bridge it back to the - // child, where each card wires its connect handler. The re-entrancy flag breaks - // the child-activated ↔ activate ping-pong that otherwise recurses forever - // (a real stack overflow on every card click; see the ignored display test). - for flow in [saved.widget(), discovered.widget()] { - let activating = std::cell::Cell::new(false); - flow.connect_child_activated(move |_, child| { - if activating.replace(true) { - return; - } - child.activate(); - activating.set(false); - }); - } - - // Shown under the discovered heading while no (unsaved) advert is live yet. - let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let spinner = gtk::Spinner::new(); - spinner.start(); - searching.append(&spinner); - let searching_label = gtk::Label::new(Some("Searching the LAN…")); - searching_label.add_css_class("dim-label"); - searching.append(&searching_label); - searching.set_margin_top(6); - searching.set_margin_bottom(6); - - let content = gtk::Box::new(gtk::Orientation::Vertical, 12); - content.set_margin_top(24); - content.set_margin_bottom(24); - content.set_margin_start(12); - content.set_margin_end(12); - content.append(&saved_heading); - content.append(saved.widget()); - content.append(&disc_heading); - content.append(&searching); - content.append(discovered.widget()); - - let clamp = adw::Clamp::builder() - .maximum_size(1100) - .child(&content) - .build(); - let scrolled = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) - .child(&clamp) - .build(); - - // No saved hosts AND nothing on the LAN → the whole page is the empty state. - let empty = adw::StatusPage::builder() - .icon_name("network-workgroup-symbolic") - .title("No hosts yet") - .description( - "Hosts on your network appear here automatically.\nAdd one by address with +.", - ) - .build(); - let add_btn = gtk::Button::with_label("Add host"); - add_btn.add_css_class("pill"); - add_btn.add_css_class("suggested-action"); - add_btn.set_halign(gtk::Align::Center); - add_btn.set_action_name(Some("win.add-host")); - empty.set_child(Some(&add_btn)); - - let stack = gtk::Stack::new(); - stack.add_named(&scrolled, Some("grid")); - stack.add_named(&empty, Some("empty")); - - // Connect failures land here, not in toasts. - let banner = adw::Banner::new(""); - banner.set_button_label(Some("Dismiss")); - banner.connect_button_clicked(|b| b.set_revealed(false)); - - let header = adw::HeaderBar::new(); - let add_host_btn = gtk::Button::from_icon_name("list-add-symbolic"); - add_host_btn.set_tooltip_text(Some("Add host")); - add_host_btn.set_action_name(Some("win.add-host")); - header.pack_start(&add_host_btn); - let menu = gio::Menu::new(); - menu.append(Some("Preferences"), Some("win.preferences")); - menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); - menu.append(Some("About Punktfunk"), Some("win.about")); - let menu_btn = gtk::MenuButton::builder() - .icon_name("open-menu-symbolic") - .menu_model(&menu) - .primary(true) - .tooltip_text("Main menu") - .build(); - header.pack_end(&menu_btn); - - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&header); - toolbar.add_top_bar(&banner); - toolbar.set_content(Some(&stack)); - page.set_child(Some(&toolbar)); - - // Rebuilt every time the page is shown, so fresh TOFU/pairing entries appear on - // return. - { - let sender = sender.clone(); - page.connect_shown(move |_| sender.input(HostsMsg::Refresh)); - } - - // Stream mDNS adverts into the model; every add/remove re-evaluates both grids. - { - let rx = discovery::browse(); - let sender = sender.clone(); - glib::spawn_future_local(async move { - while let Ok(event) = rx.recv().await { - match event { - DiscoveryEvent::Resolved(h) => sender.input(HostsMsg::Advert(h)), - DiscoveryEvent::Removed { fullname } => { - sender.input(HostsMsg::AdvertRemoved { fullname }) - } - } - } - }); - } - - // Periodic reachability sweep: a saved host reached only over a routed network - // (Tailscale/VPN) never advertises on mDNS, so presence can't come from the advert map - // alone. Each cycle probes every saved host off the main thread (bounded, trust-agnostic - // QUIC handshake — the display-side companion to dial-first) and feeds results back as - // `Probed`; the first sweep runs immediately, then every `PROBE_INTERVAL`. - { - let sender = sender.clone(); - glib::spawn_future_local(async move { - loop { - let entries: Vec<(String, String, u16)> = KnownHosts::load() - .hosts - .iter() - .filter(|h| !h.addr.is_empty()) - .map(|h| (saved_key(h), h.addr.clone(), h.port)) - .collect(); - if !entries.is_empty() { - let (tx, rx) = async_channel::bounded(1); - std::thread::Builder::new() - .name("punktfunk-probe".into()) - .spawn(move || { - let targets = - entries.iter().map(|(_, a, p)| (a.clone(), *p)).collect(); - let results = - crate::trust::probe_reachable_many(targets, PROBE_TIMEOUT); - let map: HashMap = entries - .into_iter() - .map(|(k, _, _)| k) - .zip(results) - .collect(); - let _ = tx.send_blocking(map); - }) - .expect("spawn probe thread"); - if let Ok(map) = rx.recv().await { - sender.input(HostsMsg::Probed(map)); - } - } - glib::timeout_future(PROBE_INTERVAL).await; - } - }); - } - - let mut model = HostsPage { - adverts: HashMap::new(), - probed: HashMap::new(), - connecting: None, - settings, - saved, - discovered, - widgets: PageWidgets { - stack, - banner, - saved_heading, - disc_heading, - searching, - }, - }; - model.rebuild(); - - ComponentParts { model, widgets: () } - } - - fn update(&mut self, msg: HostsMsg, sender: ComponentSender) { - match msg { - HostsMsg::Advert(h) => { - self.adverts.insert(h.key.clone(), h); - self.rebuild(); - } - HostsMsg::AdvertRemoved { fullname } => { - self.adverts.retain(|_, a| a.fullname != fullname); - self.rebuild(); - } - HostsMsg::Refresh => self.rebuild(), - HostsMsg::Probed(map) => { - self.probed = map; - self.rebuild(); - } - HostsMsg::SetConnecting(key) => { - self.connecting = key; - self.rebuild(); - } - HostsMsg::ShowError(msg) => { - self.widgets.banner.set_title(&msg); - self.widgets.banner.set_revealed(true); - } - HostsMsg::ClearError => self.widgets.banner.set_revealed(false), - HostsMsg::ShowAddHost => self.add_host_dialog(&sender), - HostsMsg::Card(out) => match out { - CardOutput::Connect(req) => { - let _ = sender.output(HostsOutput::Connect(req)); - } - CardOutput::WakeConnect(req) => { - let _ = sender.output(HostsOutput::WakeConnect(req)); - } - CardOutput::Pair(req) => { - let _ = sender.output(HostsOutput::Pair(req)); - } - CardOutput::SpeedTest(req) => { - let _ = sender.output(HostsOutput::SpeedTest(req)); - } - CardOutput::Library(req) => { - let mgmt = self.mgmt_port_for(&req); - let _ = sender.output(HostsOutput::Library(req, mgmt)); - } - CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name), - CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name), - CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()), - CardOutput::BindProfile { - fp_hex, - addr, - port, - profile_id, - } => { - // Written straight onto the host record — the binding IS a field there, so - // there is no map to keep in step (design §4.1). Matched by fingerprint - // when there is one, else by address, like every other per-host lookup. - let mut known = KnownHosts::load(); - if let Some(h) = known.hosts.iter_mut().find(|h| { - (!fp_hex.is_empty() && h.fp_hex == fp_hex) - || (h.addr == addr && h.port == port) - }) { - h.profile_id = profile_id; - if let Err(e) = known.save() { - tracing::warn!(error = %format!("{e:#}"), "saving the profile binding"); - } - } - self.rebuild(); // the chip follows immediately - } - CardOutput::CopyLink(url) => { - if let Some(display) = gtk::gdk::Display::default() { - display.clipboard().set_text(&url); - } - let _ = sender.output(HostsOutput::Toast("Link copied".into())); - } - CardOutput::CreateShortcut { label, url } => { - self.shortcut_result(&sender, &label, &url); - } - CardOutput::TogglePin { - fp_hex, - addr, - port, - profile_id, - pin, - } => { - let mut known = KnownHosts::load(); - if let Some(h) = known.hosts.iter_mut().find(|h| { - (!fp_hex.is_empty() && h.fp_hex == fp_hex) - || (h.addr == addr && h.port == port) - }) { - h.pinned_profiles.retain(|p| p != &profile_id); - if pin { - h.pinned_profiles.push(profile_id); - } - if let Err(e) = known.save() { - tracing::warn!(error = %format!("{e:#}"), "saving the pinned cards"); - } - } - self.rebuild(); - } - }, - } - } -} - -impl HostsPage { - /// Re-populate both factories from disk + the advert map. Cheap (a handful of - /// widgets) and keeps every derived view — online pips, dedup, most-recent accent, - /// spinner — in one straight-line pass. - fn rebuild(&mut self) { - let known = KnownHosts::load(); - // A saved host is ONLINE iff a live advert matches it (fingerprint, or address - // when the advert carries no fp). - let matches = |k: &KnownHost, a: &DiscoveredHost| { - (!a.fp_hex.is_empty() && a.fp_hex == k.fp_hex) || (a.addr == k.addr && a.port == k.port) - }; - let most_recent = known - .hosts - .iter() - .filter_map(|h| h.last_used.map(|t| (h.fp_hex.clone(), t))) - .max_by_key(|&(_, t)| t) - .map(|(fp, _)| fp); - let library_enabled = self.settings.borrow().library_enabled; - // One catalog read per refresh, shared by every card's menus and chip. - let profiles: Rc> = Rc::new( - pf_client_core::profiles::ProfilesFile::load() - .profiles - .into_iter() - .map(|p| (p.id, p.name)) - .collect(), - ); - - { - let mut saved = self.saved.guard(); - saved.clear(); - for k in &known.hosts { - // Online = advertising on mDNS OR proven reachable by the last probe sweep. - let online = self.adverts.values().any(|a| matches(k, a)) - || self.probed.get(&saved_key(k)).copied().unwrap_or(false); - // Learn this host's wake MAC(s) from its live advert while it's online. - if let Some(a) = self - .adverts - .values() - .find(|a| matches(k, a) && !a.mac.is_empty()) - { - crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac); - } - // Same for its OS chain — the icon then survives the host going offline. - if let Some(a) = self - .adverts - .values() - .find(|a| matches(k, a) && !a.os.is_empty()) - { - crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os); - } - saved.push_back(HostCard { - connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()), - kind: CardKind::Saved { - host: k.clone(), - online, - profiles: profiles.clone(), - recent: most_recent.as_deref() == Some(k.fp_hex.as_str()), - library_enabled, - pinned: None, - }, - }); - // …then its pinned host+profile cards, in the order the user pinned them. - // They share the host's live status because they read the same record, and a - // pin whose profile is gone simply doesn't render (design §5.2a). - for id in &k.pinned_profiles { - let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else { - continue; - }; - saved.push_back(HostCard { - // The spinner belongs to whichever card was clicked; a pin has its own - // key so two cards for one host don't both spin. - connecting: false, - kind: CardKind::Saved { - host: k.clone(), - online, - profiles: profiles.clone(), - recent: false, - library_enabled, - pinned: Some((id.clone(), name.clone())), - }, - }); - } - } - } - - // The discovered grid only surfaces genuinely-new hosts: anything matching a - // saved entry renders as that saved card (with its pip now green) instead. - let mut fresh: Vec<&DiscoveredHost> = self - .adverts - .values() - .filter(|a| !known.hosts.iter().any(|k| matches(k, a))) - .collect(); - fresh.sort_by(|a, b| a.name.cmp(&b.name).then(a.key.cmp(&b.key))); - let have_disc = !fresh.is_empty(); - { - let mut discovered = self.discovered.guard(); - discovered.clear(); - for a in fresh { - let key = if a.fp_hex.is_empty() { - format!("{}:{}", a.addr, a.port) - } else { - a.fp_hex.clone() - }; - discovered.push_back(HostCard { - connecting: self.connecting.as_deref() == Some(key.as_str()), - kind: CardKind::Discovered(a.clone()), - }); - } - } - - let have_saved = !known.hosts.is_empty(); - let w = &self.widgets; - w.saved_heading.set_visible(have_saved); - self.saved.widget().set_visible(have_saved); - w.disc_heading.set_visible(true); - self.discovered.widget().set_visible(have_disc); - w.searching.set_visible(!have_disc); - w.stack.set_visible_child_name(if have_saved || have_disc { - "grid" - } else { - "empty" - }); - } - - /// The advertised mgmt port for the host `req` points at, when a matching live - /// advert carries the `mgmt` TXT. - fn mgmt_port_for(&self, req: &ConnectRequest) -> Option { - self.adverts - .values() - .find(|a| { - req.fp_hex - .as_deref() - .is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp) - || (a.addr == req.addr && a.port == req.port) - }) - .and_then(|a| a.mgmt_port) - } - - /// Rename a saved host — an entry in an alert, then upsert + refresh. - /// Write the shortcut, or — inside the flatpak sandbox, which cannot reach - /// `~/.local/share/applications` — hand the user the URL to place themselves. The - /// DynamicLauncher portal is the intended upgrade for that case (design §5); until then - /// the fallback is the one the design already sanctions, not a dead end. - fn shortcut_result(&self, sender: &ComponentSender, label: &str, url: &str) { - if crate::shortcuts::sandboxed() { - let dialog = adw::AlertDialog::new( - Some("Create Shortcut"), - Some( - "Punktfunk is sandboxed here, so it can't add the shortcut itself. Copy \ - this link and make a launcher for it \u{2014} it opens the same stream.", - ), - ); - let entry = gtk::Entry::builder().text(url).editable(false).build(); - dialog.set_extra_child(Some(&entry)); - dialog.add_responses(&[("close", "Close"), ("copy", "Copy link")]); - dialog.set_response_appearance("copy", adw::ResponseAppearance::Suggested); - dialog.set_close_response("close"); - { - let url = url.to_string(); - dialog.connect_response(Some("copy"), move |_, _| { - if let Some(display) = gtk::gdk::Display::default() { - display.clipboard().set_text(&url); - } - }); - } - dialog.present(Some(&self.widgets.stack)); - return; - } - let msg = match crate::shortcuts::write_desktop_entry(label, url) { - Ok(_) => format!("Shortcut for \u{201c}{label}\u{201d} added to your applications"), - Err(e) => { - tracing::warn!(error = %e, "writing the shortcut"); - format!("Couldn't create the shortcut \u{2014} {e}") - } - }; - let _ = sender.output(HostsOutput::Toast(msg)); - } - - /// The host edit sheet — the per-host settings that are properties of the HOST, not of - /// the stream: its name, whether this machine shares its clipboard with it, and which - /// settings profile it defaults to. - /// - /// Linux had only "Rename" until now; the clipboard toggle in particular existed in the - /// store and on the Apple and Windows clients but had no Linux surface at all, so a Linux - /// user could not turn on a feature they were already paying the storage for. - fn edit_host_dialog(&self, sender: &ComponentSender, fp_hex: &str, current: &str) { - let stored = KnownHosts::load() - .hosts - .iter() - .find(|h| h.fp_hex == fp_hex) - .cloned(); - let name_row = adw::EntryRow::builder().title("Name").build(); - name_row.set_text(current); - let clipboard_row = adw::SwitchRow::builder() - .title("Share clipboard") - .subtitle( - "Copy and paste between this machine and that host. Per host \u{2014} handing a \ - host your clipboard is a decision about that host.", - ) - .build(); - clipboard_row.set_active(stored.as_ref().is_some_and(|h| h.clipboard_sync)); - - // Profile picker: "Default settings" plus the catalog, seeded to the current binding. - let catalog = pf_client_core::profiles::ProfilesFile::load(); - let mut labels = vec!["Default settings".to_string()]; - let mut ids: Vec = vec![String::new()]; - for p in &catalog.profiles { - labels.push(p.name.clone()); - ids.push(p.id.clone()); - } - let bound = stored.as_ref().and_then(|h| h.profile_id.clone()); - // A binding whose profile is gone reads as Default settings and is cleaned up on save - // — the same "dangling resolves as none" rule the connect path follows. - let selected = bound - .as_ref() - .and_then(|id| ids.iter().position(|i| i == id)) - .unwrap_or(0); - let profile_row = adw::ComboRow::builder() - .title("Profile") - .subtitle("The settings a plain click uses for this host") - .model(>k::StringList::new( - &labels.iter().map(String::as_str).collect::>(), - )) - .build(); - profile_row.set_selected(selected as u32); - - let list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .build(); - list.append(&name_row); - list.append(&profile_row); - list.append(&clipboard_row); - - let dialog = adw::AlertDialog::new(Some("Edit Host"), None); - dialog.set_extra_child(Some(&list)); - dialog.add_responses(&[("cancel", "Cancel"), ("save", "Save")]); - dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("save")); - dialog.set_close_response("cancel"); - { - let sender = sender.clone(); - let fp = fp_hex.to_string(); - dialog.connect_response(Some("save"), move |_, _| { - let name = name_row.text().trim().to_string(); - let mut known = KnownHosts::load(); - if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) { - if !name.is_empty() { - h.name = name; - } - h.clipboard_sync = clipboard_row.is_active(); - h.profile_id = ids - .get(profile_row.selected() as usize) - .filter(|id| !id.is_empty()) - .cloned(); - let _ = known.save(); - } - sender.input(HostsMsg::Refresh); - }); - } - dialog.present(Some(&self.widgets.stack)); - } - - /// Forget this host (drops the pinned fingerprint — a later connect re-pairs). - fn forget_dialog(&self, sender: &ComponentSender, fp_hex: &str, name: &str) { - let dialog = adw::AlertDialog::new( - Some("Remove saved host?"), - Some(&format!( - "Forget “{name}”? You'll need to pair (or trust) it again to reconnect." - )), - ); - dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]); - dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - { - let sender = sender.clone(); - let fp = fp_hex.to_string(); - dialog.connect_response(Some("remove"), move |_, _| { - let mut known = KnownHosts::load(); - known.remove_by_fp(&fp); - let _ = known.save(); - sender.input(HostsMsg::Refresh); - }); - } - dialog.present(Some(&self.widgets.stack)); - } - - /// "+": name (optional) / address / port. Submit runs the normal trust gate. - fn add_host_dialog(&self, sender: &ComponentSender) { - let list = gtk::ListBox::new(); - list.add_css_class("boxed-list"); - list.set_selection_mode(gtk::SelectionMode::None); - let name_row = adw::EntryRow::builder().title("Name (optional)").build(); - let addr_row = adw::EntryRow::builder().title("Address").build(); - let port_row = adw::EntryRow::builder().title("Port").text("9777").build(); - list.append(&name_row); - list.append(&addr_row); - list.append(&port_row); - list.set_size_request(320, -1); - - let dialog = adw::AlertDialog::new(Some("Add Host"), None); - dialog.set_extra_child(Some(&list)); - dialog.add_responses(&[("cancel", "Cancel"), ("connect", "Connect")]); - dialog.set_response_appearance("connect", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("connect")); - dialog.set_close_response("cancel"); - dialog.set_response_enabled("connect", false); - { - let dialog = dialog.clone(); - addr_row.connect_changed(move |row| { - dialog.set_response_enabled("connect", !row.text().trim().is_empty()); - }); - } - { - let sender = sender.clone(); - let (name_row, addr_row, port_row) = - (name_row.clone(), addr_row.clone(), port_row.clone()); - dialog.connect_response(Some("connect"), move |_, _| { - let text = addr_row.text().trim().to_string(); - if text.is_empty() { - return; - } - // A pasted `host:port` wins over the port field; else the field. - let (addr, port) = match text.rsplit_once(':') { - Some((a, p)) if p.parse::().is_ok() => { - (a.to_string(), p.parse::().unwrap()) - } - _ => ( - text.clone(), - port_row.text().trim().parse::().unwrap_or(9777), - ), - }; - let name = name_row.text().trim().to_string(); - let _ = sender.output(HostsOutput::Connect(ConnectRequest { - name: if name.is_empty() { addr.clone() } else { name }, - addr, - port, - fp_hex: None, - // Manual entry carries no advertised policy — never TOFU-eligible. - pair_optional: false, - launch: None, - mac: Vec::new(), - profile: None, - })); - }); - } - dialog.present(Some(&self.widgets.stack)); - } -} - -#[cfg(test)] -mod tests { - use adw::prelude::*; - use std::cell::Cell; - use std::rc::Rc; - - // Reproduces the exact FlowBox/FlowBoxChild wiring from `init()`: `child-activated` - // bridges to `child.activate()`, whose own default handler re-emits - // `child-activated` — that ping-pong recursed forever (stack overflow on every - // host-card click/Enter) until the re-entrancy guard was added. - #[test] - #[ignore = "needs a Wayland/X display"] - fn flow_box_activation_bridge_does_not_recurse() { - assert!(gtk::init().is_ok(), "no display"); - - let flow = gtk::FlowBox::builder() - .selection_mode(gtk::SelectionMode::None) - .activate_on_single_click(true) - .build(); - let activating = Cell::new(false); - flow.connect_child_activated(move |_, child| { - if activating.replace(true) { - return; - } - child.activate(); - activating.set(false); - }); - - let child = gtk::FlowBoxChild::new(); - flow.insert(&child, -1); - let fired = Rc::new(Cell::new(0u32)); - { - let fired = fired.clone(); - child.connect_activate(move |_| fired.set(fired.get() + 1)); - } - - flow.emit_by_name::<()>("child-activated", &[&child]); - - assert_eq!( - fired.get(), - 1, - "the per-card handler should fire exactly once" - ); - } -} diff --git a/clients/session/src/ui_library.rs b/clients/session/src/ui_library.rs deleted file mode 100644 index 62c278e5..00000000 --- a/clients/session/src/ui_library.rs +++ /dev/null @@ -1,365 +0,0 @@ -//! The game-library page (the Apple `LibraryView` ported): a poster grid of the host's -//! unified library fetched over the management API (`library.rs`), pushed onto the nav -//! stack from a saved card's "Browse library…" action. Poster art loads asynchronously -//! (worker threads → texture on the main loop) with a monogram placeholder, and tapping -//! a title starts a session that asks the host to launch it (the library id rides the -//! Hello via `ConnectRequest::launch`). - -use crate::app::{AppModel, AppMsg}; -use crate::library::{self, GameEntry}; -use crate::trust; -use crate::ui_hosts::ConnectRequest; -use adw::prelude::*; -use gtk::{gdk, glib}; -use relm4::prelude::*; -use std::cell::{Cell, RefCell}; -use std::collections::{HashMap, VecDeque}; -use std::rc::Rc; - -/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/ -/// card activation); dropped when the page is popped, which also winds down any in-flight -/// art consumer (its weak upgrade fails). -struct State { - sender: ComponentSender, - identity: (String, String), - /// The advertised mgmt port when the host was live at open time (else the default). - mgmt_port: u16, - /// The host this library belongs to — cards clone it and add `launch`. - req: ConnectRequest, - stack: gtk::Stack, - flow: gtk::FlowBox, - error_page: adw::StatusPage, - /// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching. - art: RefCell>, - /// The Picture each entry currently renders into (rebuilt per render), so async art - /// results land on the right card. - pics: RefCell>, - /// Screenshot mode: render injected entries only, never touch the network. - mock: Cell, -} - -/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from -/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it). -pub fn open( - app: &AppModel, - sender: &ComponentSender, - req: ConnectRequest, - mgmt_port: Option, -) { - let state = build(&app.nav, app.identity.clone(), sender, req, mgmt_port); - load(&state); -} - -/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by -/// entry id) with no host and no network — the CI `library` scene. -pub fn open_mock( - nav: &adw::NavigationView, - identity: (String, String), - sender: &ComponentSender, - req: ConnectRequest, - games: Vec, - art: Vec<(String, gdk::Texture)>, -) { - let state = build(nav, identity, sender, req, None); - state.mock.set(true); - state.art.borrow_mut().extend(art); - if games.is_empty() { - state.stack.set_visible_child_name("empty"); - } else { - render(&state, &games); - state.stack.set_visible_child_name("grid"); - } -} - -/// Build the page (loading / error / empty / grid states in a stack) and push it. -fn build( - nav: &adw::NavigationView, - identity: (String, String), - sender: &ComponentSender, - req: ConnectRequest, - mgmt_port: Option, -) -> Rc { - let flow = gtk::FlowBox::builder() - .selection_mode(gtk::SelectionMode::None) - .activate_on_single_click(true) - .homogeneous(true) - .min_children_per_line(2) - .max_children_per_line(6) - .column_spacing(12) - .row_spacing(18) - .valign(gtk::Align::Start) - .build(); - // Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own - // `activate` — bridge it so each poster's connect handler (below) runs on click. - flow.connect_child_activated(|_, child| { - child.activate(); - }); - let content = gtk::Box::new(gtk::Orientation::Vertical, 0); - content.set_margin_top(24); - content.set_margin_bottom(24); - content.set_margin_start(12); - content.set_margin_end(12); - content.append(&flow); - let clamp = adw::Clamp::builder() - .maximum_size(1100) - .child(&content) - .build(); - let scrolled = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) - .child(&clamp) - .build(); - - let loading = gtk::Box::new(gtk::Orientation::Vertical, 12); - loading.set_valign(gtk::Align::Center); - let spinner = gtk::Spinner::new(); - spinner.set_size_request(32, 32); - spinner.start(); - spinner.set_halign(gtk::Align::Center); - loading.append(&spinner); - let loading_label = gtk::Label::new(Some("Loading library…")); - loading_label.add_css_class("dim-label"); - loading.append(&loading_label); - - let error_page = adw::StatusPage::builder() - .icon_name("dialog-error-symbolic") - .title("Couldn't load the library") - .build(); - let retry = gtk::Button::with_label("Retry"); - retry.add_css_class("pill"); - retry.add_css_class("suggested-action"); - retry.set_halign(gtk::Align::Center); - error_page.set_child(Some(&retry)); - - let empty = adw::StatusPage::builder() - .icon_name("applications-games-symbolic") - .title("No games found") - .description( - "No games found on this host. Install Steam titles or add custom \ - entries in the host's web console.", - ) - .build(); - - let stack = gtk::Stack::new(); - stack.add_named(&loading, Some("loading")); - stack.add_named(&error_page, Some("error")); - stack.add_named(&empty, Some("empty")); - stack.add_named(&scrolled, Some("grid")); - - let header = adw::HeaderBar::new(); - let reload = gtk::Button::from_icon_name("view-refresh-symbolic"); - reload.set_tooltip_text(Some("Reload")); - header.pack_end(&reload); - - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&header); - toolbar.set_content(Some(&stack)); - - let page = adw::NavigationPage::builder() - .title(format!("{} — Library", req.name)) - .child(&toolbar) - .build(); - - let state = Rc::new(State { - sender: sender.clone(), - identity, - mgmt_port: mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT), - req, - stack, - flow, - error_page, - art: RefCell::new(HashMap::new()), - pics: RefCell::new(HashMap::new()), - mock: Cell::new(false), - }); - { - let state = state.clone(); - reload.connect_clicked(move |_| load(&state)); - } - { - let state = state.clone(); - retry.connect_clicked(move |_| load(&state)); - } - nav.push(&page); - state -} - -/// Fetch the library off the main thread and route the result into the grid or the -/// error/empty states. -fn load(state: &Rc) { - if state.mock.get() { - return; // screenshot scene renders injected entries only - } - state.stack.set_visible_child_name("loading"); - let port = state.mgmt_port; - let addr = state.req.addr.clone(); - let identity = state.identity.clone(); - let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); - let (tx, rx) = async_channel::bounded(1); - std::thread::Builder::new() - .name("punktfunk-library".into()) - .spawn(move || { - let _ = tx.send_blocking(library::fetch_games(&addr, port, &identity, pin)); - }) - .expect("spawn library thread"); - let weak = Rc::downgrade(state); - glib::spawn_future_local(async move { - let Ok(result) = rx.recv().await else { return }; - let Some(state) = weak.upgrade() else { return }; - match result { - Ok(games) if games.is_empty() => state.stack.set_visible_child_name("empty"), - Ok(games) => { - render(&state, &games); - state.stack.set_visible_child_name("grid"); - load_art(&state, &games); - } - Err(e) => { - state.error_page.set_description(Some(&e.to_string())); - state.stack.set_visible_child_name("error"); - } - } - }); -} - -/// (Re)build the poster grid from one library snapshot. Cached textures apply -/// immediately; the rest keep their monogram placeholder until `load_art` delivers. -fn render(state: &Rc, games: &[GameEntry]) { - state.flow.remove_all(); - state.pics.borrow_mut().clear(); - for game in games { - state.flow.append(&game_card(state, game)); - } -} - -/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a -/// monogram placeholder underneath the async art. Activation starts a session launching -/// this title (silent on a pinned host — the normal trust gate applies). -fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { - let monogram = gtk::Label::new(Some(&initials(&game.title))); - monogram.add_css_class("pf-poster-monogram"); - monogram.set_halign(gtk::Align::Center); - monogram.set_valign(gtk::Align::Center); - let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0); - placeholder.append(&monogram); - monogram.set_vexpand(true); - - let pic = gtk::Picture::new(); - pic.set_content_fit(gtk::ContentFit::Cover); - if let Some(tex) = state.art.borrow().get(&game.id) { - pic.set_paintable(Some(tex)); - } - state.pics.borrow_mut().insert(game.id.clone(), pic.clone()); - - let badge = gtk::Label::new(Some(store_label(&game.store))); - badge.add_css_class("pf-pill"); - badge.add_css_class("pf-store-badge"); - badge.set_halign(gtk::Align::Start); - badge.set_valign(gtk::Align::Start); - badge.set_margin_start(6); - badge.set_margin_top(6); - - let poster = gtk::Overlay::new(); - poster.set_child(Some(&placeholder)); - poster.add_overlay(&pic); - poster.add_overlay(&badge); - poster.add_css_class("pf-poster"); - poster.set_overflow(gtk::Overflow::Hidden); - poster.set_size_request(150, 225); - poster.set_halign(gtk::Align::Center); - - let title = gtk::Label::new(Some(&game.title)); - title.add_css_class("caption"); - title.set_ellipsize(gtk::pango::EllipsizeMode::End); - title.set_max_width_chars(16); - title.set_tooltip_text(Some(&game.title)); - - let card = gtk::Box::new(gtk::Orientation::Vertical, 6); - card.append(&poster); - card.append(&title); - - let child = gtk::FlowBoxChild::new(); - child.set_child(Some(&card)); - let sender = state.sender.clone(); - let mut req = state.req.clone(); - req.launch = Some((game.id.clone(), game.title.clone())); - child.connect_activate(move |_| sender.input(AppMsg::Connect(req.clone()))); - child -} - -/// Fetch poster art for every uncached entry on a small worker pool, walking each -/// entry's candidates in the Apple fallback order (portrait → header → hero) and -/// texturing the first that loads on the main loop. -fn load_art(state: &Rc, games: &[GameEntry]) { - let base = library::base_url(&state.req.addr, state.mgmt_port); - let jobs: VecDeque<(String, Vec)> = { - let cache = state.art.borrow(); - games - .iter() - .filter(|g| !cache.contains_key(&g.id)) - .map(|g| (g.id.clone(), g.art.poster_candidates(&base))) - .filter(|(_, candidates)| !candidates.is_empty()) - .collect() - }; - if jobs.is_empty() { - return; - } - let identity = state.identity.clone(); - let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); - let rx = library::spawn_art_fetch(base, identity, pin, jobs); - let weak = Rc::downgrade(state); - glib::spawn_future_local(async move { - while let Ok((id, bytes)) = rx.recv().await { - let Some(state) = weak.upgrade() else { break }; - // Texture decode happens here on the main loop — posters are small (tens of - // KB), and `from_bytes` handles jpeg/png alike. - match gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) { - Ok(tex) => { - if let Some(pic) = state.pics.borrow().get(&id) { - pic.set_paintable(Some(&tex)); - } - state.art.borrow_mut().insert(id, tex); - } - Err(e) => tracing::debug!(%id, error = %e, "undecodable poster"), - } - } - }); -} - -/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future -/// stores per the host's provider list), with the id prefix as a fallback spelling. -/// Shared with the gamepad launcher's posters. -pub fn store_label(store: &str) -> &'static str { - match store { - "steam" => "Steam", - "custom" => "Custom", - "heroic" => "Heroic", - "lutris" => "Lutris", - "epic" => "Epic", - "gog" => "GOG", - "xbox" => "Xbox", - _ => "Game", - } -} - -/// Monogram for the placeholder tile: the first letters of the first two words. -/// Shared with the gamepad launcher's posters. -pub fn initials(title: &str) -> String { - title - .split_whitespace() - .take(2) - .filter_map(|w| w.chars().next()) - .flat_map(char::to_uppercase) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn initials_take_two_words() { - assert_eq!(initials("Dota 2"), "D2"); - assert_eq!(initials("half-life"), "H"); - assert_eq!(initials("The Witness III"), "TW"); - assert_eq!(initials(""), ""); - } -} diff --git a/clients/session/src/ui_settings.rs b/clients/session/src/ui_settings.rs deleted file mode 100644 index 6842eeb5..00000000 --- a/clients/session/src/ui_settings.rs +++ /dev/null @@ -1,1769 +0,0 @@ -//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings -//! revamp): General / Display / Input / Audio / Controllers pages — Display owns -//! everything about the picture — with per-field captions in each row's subtitle, -//! dynamic where the meaning depends on the selection (touch mode). Written back to -//! disk when the dialog closes. About stays in the primary menu (GNOME convention) -//! rather than as a page. -//! -//! The same surface edits SETTINGS PROFILES (design/client-settings-profiles.md §5.1): a -//! scope switcher at the top swaps the whole dialog between the global defaults and one -//! profile's overrides. It is deliberately not a second editor — a parallel one would drift -//! from this one field by field. In profile scope only profileable ("tier P") rows render, -//! every row shows the EFFECTIVE value (the inherited global until you touch it), and the -//! override is recorded on touch rather than by comparing values, so a profile can pin a -//! value that happens to equal today's global and keep it when the global later moves. - -use crate::trust::Settings; -use adw::prelude::*; -use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile}; -use pf_client_core::trust::StatsVerbosity; -use std::cell::{Cell, RefCell}; -use std::collections::HashSet; -use std::rc::Rc; - -/// Which layer the dialog is editing. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Scope { - /// The global defaults every profile inherits from — the only scope before this feature. - Defaults, - /// One profile's overrides, by id. - Profile(String), -} - -/// Which rows the user actually touched this session. The override model is explicit, not -/// diffed: touching a control creates the override, and only an explicit reset removes it -/// (design §4.1). Keys are the overlay's field names, with `resolution` covering the -/// width/height/match-window tri-state that one row drives. -#[derive(Clone, Default)] -struct Touched(Rc>>); - -impl Touched { - fn mark(&self, key: &'static str) { - self.0.borrow_mut().insert(key); - } - - fn has(&self, key: &str) -> bool { - self.0.borrow().contains(key) - } - - /// Undo a touch — the user reset this row after changing it, and "back to inheriting" is - /// the later, explicit intent. - fn forget(&self, key: &str) { - self.0.borrow_mut().remove(key); - } -} - -/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect. -const RESOLUTIONS: &[(u32, u32)] = &[ - (0, 0), - (1280, 720), - (1920, 1080), - (2560, 1440), - (3840, 2160), -]; -/// `0` = the monitor's native refresh, resolved at connect. -const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; -/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]). -/// `1.0` = Native. Applied at connect and each match-window resize. -const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; - -/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a -/// full colour picker: the point is telling profiles apart at a glance on a host card, which a -/// small set of legible, contrast-checked colours does better than free choice — and the -/// schema's `#RRGGBB` still accepts anything a future picker or a hand-edit writes. -const SWATCHES: &[(&str, &str, &str)] = &[ - ("", "pf-swatch-none", "No colour"), - ("#e01b24", "pf-swatch-red", "Red"), - ("#ff7800", "pf-swatch-orange", "Orange"), - ("#f6d32d", "pf-swatch-yellow", "Yellow"), - ("#33d17a", "pf-swatch-green", "Green"), - ("#3584e4", "pf-swatch-blue", "Blue"), - ("#9141ac", "pf-swatch-purple", "Purple"), - ("#d16d9e", "pf-swatch-pink", "Pink"), - ("#77767b", "pf-swatch-slate", "Slate"), -]; - -/// A row of colour swatches, calling back with the chosen `#RRGGBB` (empty = none). Used both -/// when creating a profile and when changing one later. -fn swatch_row(current: Option<&str>, on_pick: impl Fn(String) + 'static) -> gtk::Box { - let row = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .build(); - let on_pick = Rc::new(on_pick); - let buttons: Rc>> = Rc::default(); - for (hex, class, name) in SWATCHES { - let b = gtk::Button::builder() - .css_classes(["pf-swatch", class, "circular"]) - .tooltip_text(*name) - .valign(gtk::Align::Center) - .build(); - if current.unwrap_or("") == *hex { - b.add_css_class("pf-swatch-on"); - } - { - let (on_pick, buttons, hex) = (on_pick.clone(), buttons.clone(), hex.to_string()); - b.connect_clicked(move |me| { - // The selection ring is exclusive, so clear every sibling before setting ours. - for (_, other) in buttons.borrow().iter() { - other.remove_css_class("pf-swatch-on"); - } - me.add_css_class("pf-swatch-on"); - on_pick(hex.clone()); - }); - } - buttons.borrow_mut().push((hex.to_string(), b.clone())); - row.append(&b); - } - row -} - -/// The scope switcher, plus (in profile scope) that profile's management actions. -/// -/// Switching scope does not swap the rows in place: it closes the dialog — which commits the -/// layer being edited — and asks the app to re-open in the new scope. One code path builds -/// the rows, and the commit ordering is unambiguous. -#[allow(clippy::too_many_arguments)] -fn scope_group( - dialog: &adw::PreferencesDialog, - inline: bool, - scope: &Scope, - catalog: &ProfilesFile, - active: Option<&StreamProfile>, - next_scope: &Rc>>, - parent: &impl IsA, -) -> adw::PreferencesGroup { - let g = group( - "", - "A profile overrides only what you change here; everything else follows Default \ - settings.", - ); - let mut labels: Vec = vec!["Default settings".into()]; - labels.extend(catalog.profiles.iter().map(|p| p.name.clone())); - labels.push("New profile…".into()); - let new_index = (labels.len() - 1) as u32; - let current = match scope { - Scope::Defaults => 0, - Scope::Profile(id) => catalog - .profiles - .iter() - .position(|p| &p.id == id) - .map_or(0, |i| i as u32 + 1), - }; - let row = ChoiceRow::new( - dialog, - inline, - "Editing", - "Which layer these settings belong to", - &labels.iter().map(String::as_str).collect::>(), - ); - row.set_selected(current); - { - let (dialog, next, parent) = (dialog.clone(), next_scope.clone(), parent.as_ref().clone()); - let ids: Vec = catalog.profiles.iter().map(|p| p.id.clone()).collect(); - row.connect_changed(move |i| { - if i == new_index { - // Creation is the one branch that has to ask a question first; the switch - // happens in its callback, so a cancelled prompt leaves the dialog put. - let (dialog, next) = (dialog.clone(), next.clone()); - prompt_name( - &parent, - "New profile", - "Create", - "", - true, - move |name, accent| { - let mut catalog = ProfilesFile::load(); - if catalog.name_taken(&name, None) { - return; // the prompt already refuses these; belt and braces - } - let mut profile = StreamProfile::new(name); - profile.accent = accent; - let id = profile.id.clone(); - catalog.profiles.push(profile); - if catalog.save().is_ok() { - *next.borrow_mut() = Some(Scope::Profile(id)); - dialog.close(); - } - }, - ); - return; - } - *next.borrow_mut() = Some(match i { - 0 => Scope::Defaults, - n => match ids.get(n as usize - 1) { - Some(id) => Scope::Profile(id.clone()), - None => Scope::Defaults, - }, - }); - dialog.close(); - }); - } - g.add(row.widget()); - // Leaking the row keeps its handler alive for the dialog's lifetime — the ChoiceRow owns - // the closure, and the widget alone doesn't keep it. - std::mem::forget(row); - - if let Some(active) = active { - // Colour first: it is what the profile's chips carry on host cards, so it belongs with - // the profile's identity rather than buried behind a menu. - let colour = adw::ActionRow::builder() - .title("Colour") - .subtitle("Tints this profile's chips on host cards") - .use_markup(false) - .build(); - colour.add_suffix(&swatch_row(active.accent.as_deref(), { - let id = active.id.clone(); - move |hex| { - let mut catalog = ProfilesFile::load(); - if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) { - p.accent = (!hex.is_empty()).then(|| hex.clone()); - if let Err(e) = catalog.save() { - tracing::warn!(error = %format!("{e:#}"), "saving the profile colour"); - } - } - } - })); - g.add(&colour); - let actions = adw::ActionRow::builder() - .title(&active.name) - .subtitle("This profile") - .use_markup(false) - .build(); - let buttons = gtk::Box::builder() - .spacing(6) - .valign(gtk::Align::Center) - .build(); - for (label, action) in [ - ("Rename…", ProfileAction::Rename), - ("Duplicate", ProfileAction::Duplicate), - ("Delete…", ProfileAction::Delete), - ] { - let b = gtk::Button::builder().label(label).build(); - if matches!(action, ProfileAction::Delete) { - b.add_css_class("destructive-action"); - } - let (dialog, next, parent, id, name) = ( - dialog.clone(), - next_scope.clone(), - parent.as_ref().clone(), - active.id.clone(), - active.name.clone(), - ); - b.connect_clicked(move |_| { - run_profile_action(action, &parent, &dialog, &next, &id, &name) - }); - buttons.append(&b); - } - actions.add_suffix(&buttons); - g.add(&actions); - } - g -} - -#[derive(Clone, Copy)] -enum ProfileAction { - Rename, - Duplicate, - Delete, -} - -/// Rename / duplicate / delete for the profile in scope. Each ends by closing the dialog so -/// the edit and the re-render can't disagree about what the catalog holds. -fn run_profile_action( - action: ProfileAction, - parent: >k::Widget, - dialog: &adw::PreferencesDialog, - next: &Rc>>, - id: &str, - name: &str, -) { - let (dialog, next, id) = (dialog.clone(), next.clone(), id.to_string()); - match action { - ProfileAction::Rename => { - let keep = id.clone(); - prompt_name( - parent, - "Rename profile", - "Rename", - name, - false, - move |new_name, _| { - let mut catalog = ProfilesFile::load(); - if catalog.name_taken(&new_name, Some(&keep)) { - return; - } - if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == keep) { - p.name = new_name; - } - if catalog.save().is_ok() { - *next.borrow_mut() = Some(Scope::Profile(keep.clone())); - dialog.close(); - } - }, - ); - } - ProfileAction::Duplicate => { - let mut catalog = ProfilesFile::load(); - let Some(source) = catalog.find_by_id(&id).cloned() else { - return; - }; - // "Work 2", "Work 3", … — the first name the catalog doesn't already hold. - let copy_name = (2..) - .map(|n| format!("{} {n}", source.name)) - .find(|n| !catalog.name_taken(n, None)) - .unwrap_or_else(|| source.name.clone()); - let mut copy = StreamProfile::new(copy_name); - copy.overrides = source.overrides.clone(); - copy.accent = source.accent.clone(); - let new_id = copy.id.clone(); - catalog.profiles.push(copy); - if catalog.save().is_ok() { - *next.borrow_mut() = Some(Scope::Profile(new_id)); - dialog.close(); - } - } - ProfileAction::Delete => { - // The warning counts what actually breaks: hosts that fall back to the defaults, - // and pinned cards that disappear (design §6). - let known = crate::trust::KnownHosts::load(); - let bound = known - .hosts - .iter() - .filter(|h| h.profile_id.as_deref() == Some(id.as_str())) - .count(); - let pinned = known - .hosts - .iter() - .filter(|h| h.pinned_profiles.iter().any(|p| p == &id)) - .count(); - let mut body = format!("“{name}” will be removed."); - if bound > 0 { - body.push_str(&format!( - "\n\n{bound} host{} will fall back to Default settings.", - if bound == 1 { "" } else { "s" } - )); - } - if pinned > 0 { - body.push_str(&format!( - "\n{pinned} pinned card{} will disappear.", - if pinned == 1 { "" } else { "s" } - )); - } - let confirm = adw::AlertDialog::new(Some("Delete profile?"), Some(&body)); - confirm.add_responses(&[("cancel", "Cancel"), ("delete", "Delete")]); - confirm.set_response_appearance("delete", adw::ResponseAppearance::Destructive); - confirm.set_close_response("cancel"); - confirm.connect_response(Some("delete"), move |_, _| { - let mut catalog = ProfilesFile::load(); - catalog.profiles.retain(|p| p.id != id); - if catalog.save().is_ok() { - // Bindings and pins are left dangling on purpose: they resolve as "no - // profile" everywhere, and rewriting every host record here would be a - // second, racier source of truth. - *next.borrow_mut() = Some(Scope::Defaults); - dialog.close(); - } - }); - confirm.present(Some(parent)); - } - } -} - -/// A one-line name prompt (create/rename). Refuses empty and duplicate names in place — -/// menus keyed by name are ambiguous otherwise (design §6) — rather than failing after the -/// dialog is gone. -fn prompt_name( - parent: &impl IsA, - heading: &str, - accept: &str, - initial: &str, - with_colour: bool, - on_ok: impl Fn(String, Option) + 'static, -) { - let dialog = adw::AlertDialog::new(Some(heading), None); - let entry = adw::EntryRow::builder().title("Name").build(); - entry.set_text(initial); - let list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .build(); - list.append(&entry); - // Creating a profile picks its colour here, in the same breath as its name — going hunting - // for it afterwards is exactly the friction that leaves every profile grey. - let accent: Rc>> = Rc::default(); - if with_colour { - let row = adw::ActionRow::builder() - .title("Colour") - .use_markup(false) - .build(); - row.add_suffix(&swatch_row(None, { - let accent = accent.clone(); - move |hex| *accent.borrow_mut() = (!hex.is_empty()).then(|| hex.clone()) - })); - list.append(&row); - } - dialog.set_extra_child(Some(&list)); - dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]); - dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("ok")); - dialog.set_close_response("cancel"); - let taken_against = initial.to_string(); - let e = entry.clone(); - let d = dialog.clone(); - let validate = move || { - let name = e.text().trim().to_string(); - let catalog = ProfilesFile::load(); - let dup = !name.eq_ignore_ascii_case(&taken_against) && catalog.name_taken(&name, None); - d.set_response_enabled("ok", !name.is_empty() && !dup); - e.set_title(if dup { - "Name — already used by another profile" - } else { - "Name" - }); - }; - validate(); - { - let validate = validate.clone(); - entry.connect_changed(move |_| validate()); - } - let e = entry.clone(); - dialog.connect_response(Some("ok"), move |_, _| { - let name = e.text().trim().to_string(); - if !name.is_empty() { - on_ok(name, accent.borrow().clone()); - } - }); - dialog.present(Some(parent)); -} - -/// Write the rows the user touched into this profile's overlay and persist the catalog. -/// -/// Only touched fields move: an untouched row leaves whatever the profile already had -/// (an inherited `None`, or an existing override this build might not even render), which is -/// what keeps an older client from erasing a newer one's values just by opening the dialog. -/// The catalog is re-read here rather than reused, so a profile renamed in another window -/// between opening and closing this one survives. -fn commit_profile( - active: &StreamProfile, - touched: &Touched, - cleared: &HashSet<&'static str>, - values: &Settings, -) { - let mut catalog = ProfilesFile::load(); - let Some(slot) = catalog.profiles.iter_mut().find(|p| p.id == active.id) else { - return; // deleted from under us — nothing to write to, and nothing to complain about - }; - let o: &mut SettingsOverlay = &mut slot.overrides; - if touched.has("resolution") { - // One row drives the tri-state, so all three fields move together. - o.match_window = Some(values.match_window); - o.width = Some(values.width); - o.height = Some(values.height); - } - if touched.has("refresh_hz") { - o.refresh_hz = Some(values.refresh_hz); - } - if touched.has("render_scale") { - o.render_scale = Some(values.render_scale); - } - if touched.has("bitrate_kbps") { - o.bitrate_kbps = Some(values.bitrate_kbps); - } - if touched.has("codec") { - o.codec = Some(values.codec.clone()); - } - if touched.has("hdr_enabled") { - o.hdr_enabled = Some(values.hdr_enabled); - } - if touched.has("enable_444") { - o.enable_444 = Some(values.enable_444); - } - if touched.has("compositor") { - o.compositor = Some(values.compositor.clone()); - } - if touched.has("audio_channels") { - o.audio_channels = Some(values.audio_channels); - } - if touched.has("mic_enabled") { - o.mic_enabled = Some(values.mic_enabled); - } - if touched.has("touch_mode") { - o.touch_mode = Some(values.touch_mode.clone()); - } - if touched.has("mouse_mode") { - o.mouse_mode = Some(values.mouse_mode.clone()); - } - if touched.has("invert_scroll") { - o.invert_scroll = Some(values.invert_scroll); - } - if touched.has("inhibit_shortcuts") { - o.inhibit_shortcuts = Some(values.inhibit_shortcuts); - } - if touched.has("gamepad") { - o.gamepad = Some(values.gamepad.clone()); - } - if touched.has("stats_verbosity") { - o.stats_verbosity = Some(values.stats_verbosity()); - } - if touched.has("fullscreen_on_stream") { - o.fullscreen_on_stream = Some(values.fullscreen_on_stream); - } - // Resets last: a row the user reset may also have fired its change handler on the way - // (a ComboRow re-seeds), and "back to inheriting" is the later, explicit intent. The field - // names are the overlay's own, so the list of what can be cleared lives in ONE place — - // this used to be a second `match` here, which is exactly how the two drift. - for key in cleared { - if !o.clear(key) { - tracing::warn!(field = key, "reset of an unknown overlay field"); - } - } - if let Err(e) = catalog.save() { - tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog"); - } -} - -/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)". -fn render_scale_label(scale: f64) -> String { - if scale == 1.0 { - "Native".to_string() - } else if scale > 1.0 { - format!("{scale}× (supersample)") - } else { - format!("{scale}×") - } -} -const GAMEPADS: &[&str] = &[ - "auto", - "xbox360", - "dualsense", - "xboxone", - "dualshock4", - "steamdeck", -]; -const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; -/// Codec setting values (persisted) paired with their display labels below. PyroWave is -/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to -/// HEVC when either side can't do it. -const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"]; -const CODEC_LABELS: &[&str] = &[ - "Automatic", - "HEVC (H.265)", - "H.264 (AVC)", - "AV1", - "PyroWave (wired LAN)", -]; -const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"]; -/// Touch-input model values (persisted) paired with their display labels below — the -/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet). -const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"]; -const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"]; -/// The SELECTED touch mode explained — the caption swaps with the choice (the Apple -/// revamp's dynamic-caption idiom) instead of narrating all three modes at once. -/// Combo-row captions must stay ONE line (~66 chars at the default dialog width): a -/// wrapped subtitle's natural width crushes the selected-value label into an ellipsis. -const TOUCH_MODE_CAPTIONS: &[&str] = &[ - "Drives the cursor like a laptop trackpad — tap to click", - "The cursor jumps to your finger — a tap clicks there", - "Real multi-touch reaches the host — for touch-native apps", -]; -/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as -/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream. -const MOUSE_MODES: &[&str] = &["capture", "desktop"]; -const MOUSE_MODE_LABELS: &[&str] = &["Capture (games)", "Desktop (absolute)"]; -const MOUSE_MODE_CAPTIONS: &[&str] = &[ - "Pointer locks to the stream — relative motion, best for games", - "Pointer moves freely in and out — best for remote desktop work", -]; - -/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page. -const APP_LICENSE: &str = concat!( - "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n", - "================================ MIT ================================\n\n", - include_str!("../../../LICENSE-MIT"), - "\n\n=============================== Apache-2.0 ===============================\n\n", - include_str!("../../../LICENSE-APACHE"), -); -/// Third-party software notices for the linked Rust crates (generated by -/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog). -const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt"); - -/// Show the About dialog (app license + the third-party-software Legal section) — reached -/// from the primary menu (app.rs `win.about`). -pub fn show_about(parent: &impl IsA) { - let about = adw::AboutDialog::builder() - .application_name("Punktfunk") - .developer_name("unom") - .version(env!("CARGO_PKG_VERSION")) - .website("https://git.unom.io/unom/punktfunk") - .license_type(gtk::License::Custom) - .license(APP_LICENSE) - .build(); - // The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own - // (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set. - about.add_legal_section( - "Third-party software (Rust crates)", - None, - gtk::License::Custom, - Some(THIRD_PARTY_NOTICES), - ); - about.add_legal_section( - "Third-party software (system libraries)", - None, - gtk::License::Custom, - Some( - "This application dynamically links system libraries under their own licenses, \ - including FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), \ - and SDL 3 (Zlib). Their full license texts are available from each project.", - ), - ); - about.present(Some(parent)); -} - -/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers -/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown -/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel. -fn gamescope_session() -> bool { - std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope")) - || std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok() -} - -type ChangedFn = Rc>>>; - -/// A titled single-choice preference row. On a desktop this is a stock popover -/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable -/// row that pushes an in-window selection subpage onto the preferences dialog instead. -struct ChoiceRow { - row: adw::PreferencesRow, - selected: Rc>, - /// Fires on user changes only — handlers are installed after seeding, so programmatic - /// `set_selected` during setup never fires them. A list, not one: a row can carry both a - /// dynamic caption and (in profile scope) the override mark. - changed: ChangedFn, - /// Subpage mode only: the current value rendered as the row's suffix. - value_label: Option, - options: Rc>, -} - -impl ChoiceRow { - /// `inline` = subpage mode (gamescope): computed once per dialog via - /// [`gamescope_session`] and passed in so tests can drive both modes directly. - fn new( - dialog: &adw::PreferencesDialog, - inline: bool, - title: &str, - subtitle: &str, - options: &[&str], - ) -> ChoiceRow { - let options: Rc> = Rc::new(options.iter().map(|s| s.to_string()).collect()); - let selected = Rc::new(Cell::new(0u32)); - let changed: ChangedFn = Rc::new(RefCell::new(Vec::new())); - - if !inline { - let row = adw::ComboRow::builder() - .title(title) - .subtitle(subtitle) - .model(>k::StringList::new( - &options.iter().map(String::as_str).collect::>(), - )) - .build(); - let (sel, chg) = (selected.clone(), changed.clone()); - row.connect_selected_notify(move |r| { - if sel.replace(r.selected()) != r.selected() { - for f in chg.borrow().iter() { - f(r.selected()); - } - } - }); - return ChoiceRow { - row: row.upcast(), - selected, - changed, - value_label: None, - options, - }; - } - - let value = gtk::Label::builder().css_classes(["dim-label"]).build(); - let row = adw::ActionRow::builder() - .title(title) - .subtitle(subtitle) - .activatable(true) - .build(); - row.add_suffix(&value); - row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); - { - let dialog = dialog.downgrade(); - let (options, sel, chg, value) = ( - options.clone(), - selected.clone(), - changed.clone(), - value.clone(), - ); - let title = title.to_string(); - row.connect_activated(move |_| { - let Some(dialog) = dialog.upgrade() else { - return; - }; - let list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .build(); - for (i, opt) in options.iter().enumerate() { - let check = gtk::Image::from_icon_name("object-select-symbolic"); - check.set_visible(i as u32 == sel.get()); - let opt_row = adw::ActionRow::builder() - .title(opt) - .use_markup(false) - .activatable(true) - .build(); - opt_row.add_suffix(&check); - let idx = i as u32; - let dlg = dialog.downgrade(); - let (sel, chg, value, label) = - (sel.clone(), chg.clone(), value.clone(), opt.clone()); - opt_row.connect_activated(move |_| { - let user_change = sel.replace(idx) != idx; - value.set_text(&label); - if user_change { - for f in chg.borrow().iter() { - f(idx); - } - } - if let Some(d) = dlg.upgrade() { - d.pop_subpage(); - } - }); - list.append(&opt_row); - } - let clamp = adw::Clamp::builder() - .child(&list) - .margin_top(24) - .margin_bottom(24) - .margin_start(12) - .margin_end(12) - .build(); - let scroll = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) - .child(&clamp) - .build(); - let view = adw::ToolbarView::new(); - view.add_top_bar(&adw::HeaderBar::new()); - view.set_content(Some(&scroll)); - dialog.push_subpage(&adw::NavigationPage::new(&view, &title)); - }); - } - let cr = ChoiceRow { - row: row.upcast(), - selected, - changed, - value_label: Some(value), - options, - }; - cr.sync_value(); - cr - } - - /// Subpage mode: reflect the current selection in the row's suffix label. - fn sync_value(&self) { - if let Some(l) = &self.value_label { - let i = self.selected.get() as usize; - l.set_text(self.options.get(i).map(String::as_str).unwrap_or("")); - } - } - - fn widget(&self) -> &adw::PreferencesRow { - &self.row - } - - fn selected(&self) -> u32 { - self.selected.get() - } - - fn set_selected(&self, i: u32) { - if let Some(combo) = self.row.downcast_ref::() { - combo.set_selected(i); // the notify handler syncs the cell - } else { - self.selected.set(i); - self.sync_value(); - } - } - - fn connect_changed(&self, f: impl Fn(u32) + 'static) { - self.changed.borrow_mut().push(Box::new(f)); - } -} - -/// Update a row's caption after construction — the dynamic-caption hook (touch mode, -/// resolution, codec). Both ChoiceRow shapes carry their subtitle on [`adw::ActionRow`] -/// ([`adw::ComboRow`] derives from it), so one downcast covers desktop and gamescope mode. -fn set_row_subtitle(row: &adw::PreferencesRow, text: &str) { - if let Some(r) = row.downcast_ref::() { - r.set_subtitle(text); - } -} - -/// The SELECTED resolution choice explained (row index: 0 = Native, 1 = Match window, -/// 2.. = explicit sizes) — one line each, see the caption-width note on -/// [`TOUCH_MODE_CAPTIONS`]. -fn resolution_caption(i: u32) -> &'static str { - match i { - 0 => "The native mode of this monitor, resolved at connect", - 1 => "Follows the stream window — resizes renegotiate the host output", - _ => "The host drives a virtual output at exactly this size", - } -} - -/// The SELECTED codec explained: the PyroWave entry is the one that needs its trade-off -/// spelled out; everything else shares the soft-preference line. -fn codec_caption(i: u32) -> &'static str { - if CODECS.get(i as usize) == Some(&"pyrowave") { - "Wavelet codec for wired LAN — minimal latency, lots of bandwidth" - } else { - "A preference — the host falls back if it can't encode it" - } -} - -/// A settings category page for the dialog's view switcher. -fn page(title: &str, icon: &str) -> adw::PreferencesPage { - adw::PreferencesPage::builder() - // The name addresses the page programmatically (`set_visible_page_name` — the - // screenshot harness's page knob); the title is what the view switcher shows. - .name(title.to_lowercase()) - .title(title) - .icon_name(icon) - .build() -} - -/// Startup device probes for the pickers — filled by the app shell in the background -/// (GPUs via `punktfunk-session --list-adapters`, audio endpoints via the PipeWire -/// registry); any list may still be empty when the dialog opens, which simply hides -/// that picker. -#[derive(Default)] -pub struct DeviceProbes { - pub adapters: Vec, - pub speakers: Vec, - pub mics: Vec, -} - -/// A titled group of rows; `description` (may be empty) is the one form-level note — -/// per-field explanations belong in row subtitles, not here. -fn group(title: &str, description: &str) -> adw::PreferencesGroup { - let g = adw::PreferencesGroup::builder().title(title).build(); - if !description.is_empty() { - g.set_description(Some(description)); - } - g -} - -/// The dialog in a given [`Scope`]. `on_scope` asks the app to re-open it in another one: -/// switching scope closes this dialog first, so the layer being edited is committed before -/// the next one is loaded, and there is exactly one place that builds the rows. -pub fn show_scoped( - parent: &impl IsA, - settings: Rc>, - gamepads: &crate::gamepad::GamepadService, - probes: &DeviceProbes, - scope: Scope, - on_scope: impl Fn(Scope) + 'static, - on_closed: impl Fn() + 'static, -) -> adw::PreferencesDialog { - let catalog = ProfilesFile::load(); - // A scope pointing at a deleted profile degrades to the defaults rather than erroring — - // the same rule a dangling host binding follows. - let active: Option = match &scope { - Scope::Profile(id) => catalog.find_by_id(id).cloned(), - Scope::Defaults => None, - }; - let profile_mode = active.is_some(); - // Rows always show the EFFECTIVE value: the global underneath, with this profile's - // overrides on top. A row the profile doesn't override therefore reads as the live - // global, which is what "inherit by default" has to look like. - let seed: Settings = match &active { - Some(p) => p.overrides.apply(&settings.borrow()), - None => settings.borrow().clone(), - }; - let touched = Touched::default(); - // Fields whose override a per-row reset dropped — applied at commit, after the touched - // ones, so "reset" wins over a value the same row happens to be showing. - let cleared: Rc>> = Rc::default(); - // Where a scope switch wants to go once this dialog has committed and closed. - let next_scope: Rc>> = Rc::default(); - - // The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection - // subpage onto it. - let dialog = adw::PreferencesDialog::new(); - dialog.set_title("Preferences"); - dialog.set_search_enabled(true); - // Wide enough that the category switcher sits in the HEADER BAR (the tabbed look the - // Apple/Windows clients have): AdwPreferencesDialog moves it to a bottom bar below a - // breakpoint of 110pt × page count (≈ 733 px for our five pages). In a window that - // can't give the dialog this width it still collapses to the bottom bar on its own. - dialog.set_content_width(830); - let inline = gamescope_session(); - - // ---- Display: Resolution ---- - // The D1 tri-state: Native, Match window (a virtual index 1, stored as the - // `match_window` flag), then the explicit sizes. - let res_names: Vec = std::iter::once("Native display".to_string()) - .chain(std::iter::once("Match window".to_string())) - .chain( - RESOLUTIONS - .iter() - .skip(1) - .map(|&(w, h)| format!("{w} × {h}")), - ) - .collect(); - let res_row = ChoiceRow::new( - &dialog, - inline, - "Resolution", - resolution_caption(0), - &res_names.iter().map(String::as_str).collect::>(), - ); - { - let w = res_row.widget().clone(); - res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i))); - } - let hz_names: Vec = REFRESH - .iter() - .map(|&r| { - if r == 0 { - "Native".to_string() - } else { - format!("{r} Hz") - } - }) - .collect(); - let hz_row = ChoiceRow::new( - &dialog, - inline, - "Refresh rate", - "Native follows the monitor the window is on", - &hz_names.iter().map(String::as_str).collect::>(), - ); - - // ---- Display: Quality ---- - let scale_names: Vec = RENDER_SCALES - .iter() - .map(|&s| render_scale_label(s)) - .collect(); - let scale_row = ChoiceRow::new( - &dialog, - inline, - "Render scale", - "Above 1× supersamples for sharpness; below is lighter on the host", - &scale_names.iter().map(String::as_str).collect::>(), - ); - let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0); - bitrate_row.set_title("Bitrate"); - bitrate_row - .set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test"); - let codec_row = ChoiceRow::new( - &dialog, - inline, - "Video codec", - codec_caption(0), - CODEC_LABELS, - ); - { - let w = codec_row.widget().clone(); - codec_row.connect_changed(move |i| set_row_subtitle(&w, codec_caption(i))); - } - let hdr_row = adw::SwitchRow::builder() - .title("10-bit HDR") - .subtitle( - "Advertise 10-bit HDR10 so the host upgrades HDR content — shown in HDR where \ - the display supports it, tone-mapped otherwise", - ) - .build(); - let chroma_row = adw::SwitchRow::builder() - .title("Full chroma (4:4:4)") - .subtitle( - "Ask for full-colour video instead of subsampled \u{2014} small text and thin lines \ - stay crisp, at more bandwidth. HEVC only, and only when the host and its GPU can \ - encode it; otherwise the stream stays 4:2:0.", - ) - .build(); - let decoder_row = ChoiceRow::new( - &dialog, - inline, - "Video decoder", - "Automatic picks the best hardware decode, then software", - &["Automatic", "Vulkan Video", "VAAPI", "Software"], - ); - // GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick - // via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to - // pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry. - let saved_adapter = seed.adapter.clone(); - let mut gpu_names = vec!["Automatic".to_string()]; - let mut gpu_keys: Vec = vec![String::new()]; - for a in &probes.adapters { - gpu_names.push(a.clone()); - gpu_keys.push(a.clone()); - } - if !saved_adapter.is_empty() && !gpu_keys.contains(&saved_adapter) { - gpu_names.push(format!("{saved_adapter} (not detected)")); - gpu_keys.push(saved_adapter.clone()); - } - let gpu_row = (gpu_keys.len() > 1).then(|| { - let row = ChoiceRow::new( - &dialog, - inline, - "GPU", - "Decodes and presents the stream", - &gpu_names.iter().map(String::as_str).collect::>(), - ); - let i = gpu_keys - .iter() - .position(|k| k == &saved_adapter) - .unwrap_or(0); - row.set_selected(i as u32); - row - }); - - // ---- Display: Host output ---- - let compositor_row = ChoiceRow::new( - &dialog, - inline, - "Host compositor", - "Advisory — the host falls back to auto-detect when unavailable", - &[ - "Automatic", - "KWin", - "wlroots (Sway/Hyprland)", - "Mutter (GNOME)", - "gamescope", - ], - ); - - // ---- General ---- - let fullscreen_row = adw::SwitchRow::builder() - .title("Start streams in fullscreen") - .subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out") - .build(); - let wake_row = adw::SwitchRow::builder() - .title("Auto-wake on connect") - .subtitle( - "Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \ - off if hosts behind a VPN look offline when they aren't", - ) - .build(); - let stats_row = ChoiceRow::new( - &dialog, - inline, - "Statistics overlay", - "Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live", - &["Off", "Compact", "Normal", "Detailed"], - ); - let library_row = adw::SwitchRow::builder() - .title("Show game library") - .subtitle( - "Adds “Browse library…” to paired hosts — list their Steam and custom games \ - and launch one directly. No extra host setup", - ) - .build(); - - // ---- Input ---- - let touch_row = ChoiceRow::new( - &dialog, - inline, - "Touch input", - TOUCH_MODE_CAPTIONS[0], - TOUCH_MODE_LABELS, - ); - // Dynamic caption: describe the SELECTED mode, not all three at once. - { - let w = touch_row.widget().clone(); - touch_row.connect_changed(move |i| { - let i = (i as usize).min(TOUCH_MODE_CAPTIONS.len() - 1); - set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]); - }); - } - let mouse_row = ChoiceRow::new( - &dialog, - inline, - "Mouse input", - MOUSE_MODE_CAPTIONS[0], - MOUSE_MODE_LABELS, - ); - { - let w = mouse_row.widget().clone(); - mouse_row.connect_changed(move |i| { - let i = (i as usize).min(MOUSE_MODE_CAPTIONS.len() - 1); - set_row_subtitle(&w, MOUSE_MODE_CAPTIONS[i]); - }); - } - let inhibit_row = adw::SwitchRow::builder() - .title("Capture system shortcuts") - .subtitle("Forward Alt+Tab, Super, … to the host while input is captured") - .build(); - let invert_row = adw::SwitchRow::builder() - .title("Invert scroll direction") - .subtitle("Reverses the wheel and trackpad scroll direction sent to the host") - .build(); - - // ---- Audio ---- - let surround_row = ChoiceRow::new( - &dialog, - inline, - "Audio channels", - "Stereo or surround — the host downmixes if its output has fewer", - &["Stereo", "5.1 Surround", "7.1 Surround"], - ); - let mic_row = adw::SwitchRow::builder() - .title("Stream microphone") - .subtitle("Sends your microphone to the host's virtual mic") - .build(); - // Endpoint pickers (from the PipeWire probe): visible labels are descriptions, the - // stored value is the node name. Hidden when the probe found nothing; a saved - // device that's gone keeps a revertable "(not detected)" entry, like the GPU row. - let dev_row = |saved: String, - devs: &[pf_client_core::audio::AudioDevice], - title: &str, - subtitle: &str| { - let mut names = vec!["System default".to_string()]; - let mut keys = vec![String::new()]; - for d in devs { - names.push(d.description.clone()); - keys.push(d.name.clone()); - } - if !saved.is_empty() && !keys.contains(&saved) { - names.push(format!("{saved} (not detected)")); - keys.push(saved.clone()); - } - let row = (keys.len() > 1).then(|| { - let row = ChoiceRow::new( - &dialog, - inline, - title, - subtitle, - &names.iter().map(String::as_str).collect::>(), - ); - row.set_selected(keys.iter().position(|k| k == &saved).unwrap_or(0) as u32); - row - }); - (row, keys) - }; - let (speaker_row, speaker_keys) = dev_row( - settings.borrow().speaker_device.clone(), - &probes.speakers, - "Speaker", - "Host audio plays here — System default follows the desktop", - ); - let (micdev_row, micdev_keys) = dev_row( - settings.borrow().mic_device.clone(), - &probes.mics, - "Microphone", - "The input that feeds the host's virtual mic", - ); - // The device pick only matters while the mic streams at all. - if let Some(r) = &micdev_row { - let w = r.widget().clone(); - w.set_sensitive(mic_row.is_active()); - mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active())); - } - - // ---- Controllers ---- - // Controller forwarding: Automatic forwards EVERY real controller, each as its own pad - // (Steam's virtual pad skipped); pinning one restricts the session to that single - // controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`), - // so it survives restarts — and disconnects: an offline pinned pad keeps its entry here - // instead of silently snapping back to Automatic. - let pads = gamepads.pads(); - let saved_pin = settings.borrow().forward_pad.clone(); - let mut pad_names = vec!["Automatic (all controllers)".to_string()]; - let mut pad_keys: Vec = Vec::new(); - for p in &pads { - let kind = p.kind_label(); - pad_names.push(if kind.is_empty() { - p.name.clone() - } else { - format!("{} · {kind}", p.name) - }); - pad_keys.push(p.key.clone()); - } - if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) { - let name = saved_pin - .splitn(3, ':') - .nth(2) - .unwrap_or("Saved controller"); - pad_names.push(format!("{name} (not connected)")); - pad_keys.push(saved_pin.clone()); - } - let forward_row = ChoiceRow::new( - &dialog, - inline, - "Forwarded controller", - if pads.is_empty() { - "No controllers detected" - } else { - "Every pad is its own player — pick one to force single-player" - }, - &pad_names.iter().map(String::as_str).collect::>(), - ); - let pinned_i = pad_keys - .iter() - .position(|k| k == &saved_pin) - .map_or(0, |i| i + 1); - forward_row.set_selected(pinned_i as u32); - // The dialog-local choice, written into Settings on close (reading the service back - // would race its worker thread applying the Pin message). - let chosen_pin: Rc> = Rc::new(RefCell::new(saved_pin)); - { - let svc = gamepads.clone(); - let keys = pad_keys.clone(); - let chosen = chosen_pin.clone(); - forward_row.connect_changed(move |sel| { - let key = if sel == 0 { - None - } else { - keys.get(sel as usize - 1).cloned() - }; - *chosen.borrow_mut() = key.clone().unwrap_or_default(); - svc.set_pinned(key); - }); - } - let pad_row = ChoiceRow::new( - &dialog, - inline, - "Gamepad type", - "The virtual pad on the host — Automatic matches your controller", - &[ - "Automatic", - "Xbox 360", - "DualSense", - "Xbox One", - "DualShock 4", - "Steam Deck", - ], - ); - - // ---- Seed from the effective settings for this scope ---- - { - let s = &seed; - let res_i = if s.match_window { - 1 - } else { - RESOLUTIONS - .iter() - .position(|&(w, h)| w == s.width && h == s.height) - .map(|i| if i == 0 { 0 } else { i + 1 }) - .unwrap_or(0) - }; - res_row.set_selected(res_i as u32); - set_row_subtitle(res_row.widget(), resolution_caption(res_i as u32)); - let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0); - hz_row.set_selected(hz_i as u32); - let scale_i = RENDER_SCALES - .iter() - .position(|&x| (x - s.render_scale).abs() < 1e-6) - .unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap()); - scale_row.set_selected(scale_i as u32); - bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); - let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0); - pad_row.set_selected(pad_i as u32); - let touch_i = TOUCH_MODES - .iter() - .position(|&t| t == s.touch_mode) - .unwrap_or(0); - touch_row.set_selected(touch_i as u32); - // set_selected never fires the changed hook, so seed the dynamic caption directly. - set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]); - let mouse_i = MOUSE_MODES - .iter() - .position(|&m| m == s.mouse_mode) - .unwrap_or(0); - mouse_row.set_selected(mouse_i as u32); - set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i]); - let comp_i = COMPOSITORS - .iter() - .position(|&c| c == s.compositor) - .unwrap_or(0); - compositor_row.set_selected(comp_i as u32); - let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); - decoder_row.set_selected(dec_i as u32); - let stats_i = StatsVerbosity::ALL - .iter() - .position(|v| *v == s.stats_verbosity()) - .unwrap_or(0); - stats_row.set_selected(stats_i as u32); - fullscreen_row.set_active(s.fullscreen_on_stream); - wake_row.set_active(s.auto_wake); - inhibit_row.set_active(s.inhibit_shortcuts); - invert_row.set_active(s.invert_scroll); - mic_row.set_active(s.mic_enabled); - hdr_row.set_active(s.hdr_enabled); - chroma_row.set_active(s.enable_444); - library_row.set_active(s.library_enabled); - surround_row.set_selected(match s.audio_channels { - 6 => 1, - 8 => 2, - _ => 0, - }); - let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0); - codec_row.set_selected(codec_i as u32); - set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32)); - } - - // ---- Override markers, per-row reset, and the touch that creates an override ---- - // One pass per row, because the three are the same fact seen from different sides: the - // marker says "this profile changes this", the reset is the only way back (the override - // model never infers "not overridden" from a value comparison), and touching the control - // is what creates it. The marker therefore has to appear ON TOUCH, not on the next open — - // a user who changes a row and sees nothing has no reason to believe it took. - // - // Wired after the seed block on purpose: `set_selected`/`set_active` during setup must not - // look like the user touching anything, or opening a profile would override every row. - if let Some(active) = &active { - let o = &active.overrides; - // Build the marker for one row and hand back a "now it's overridden" switch its - // control's change handler can pull. - let mark = |row: &adw::PreferencesRow, - key: &'static str, - overridden: bool| - -> Option> { - let row = row.downcast_ref::()?; - let dot = gtk::Box::builder() - .css_classes(["pf-override-dot"]) - .valign(gtk::Align::Center) - .visible(overridden) - .build(); - row.add_prefix(&dot); - let reset = gtk::Button::builder() - .icon_name("edit-undo-symbolic") - .tooltip_text("Reset to Default settings") - .valign(gtk::Align::Center) - .visible(overridden) - .css_classes(["flat"]) - .build(); - { - let (dialog, next, cleared, scope) = ( - dialog.clone(), - next_scope.clone(), - cleared.clone(), - scope.clone(), - ); - let touched = touched.clone(); - reset.connect_clicked(move |_| { - // Queue the clear and re-open in the same scope: the close handler commits - // whatever else was edited first, then drops this field, and the rebuilt - // rows show the inherited value. Dropping it from `touched` too keeps a - // reset-after-touch from re-writing what the reset just removed. - touched.forget(key); - cleared.borrow_mut().insert(key); - *next.borrow_mut() = Some(scope.clone()); - dialog.close(); - }); - } - row.add_suffix(&reset); - Some(Box::new(move || { - dot.set_visible(true); - reset.set_visible(true); - })) - }; - - // `(row widget, overlay field, is it overridden right now)` — then the control's own - // change signal marks it touched AND lights the marker. - macro_rules! choice { - ($row:expr, $key:literal, $overridden:expr) => {{ - let show = mark($row.widget(), $key, $overridden); - let t = touched.clone(); - $row.connect_changed(move |_| { - t.mark($key); - if let Some(show) = &show { - show(); - } - }); - }}; - } - macro_rules! toggle { - ($row:expr, $key:literal, $overridden:expr) => {{ - let show = mark($row.upcast_ref(), $key, $overridden); - let t = touched.clone(); - $row.connect_active_notify(move |_| { - t.mark($key); - if let Some(show) = &show { - show(); - } - }); - }}; - } - - choice!( - res_row, - "resolution", - o.width.is_some() || o.height.is_some() || o.match_window.is_some() - ); - choice!(hz_row, "refresh_hz", o.refresh_hz.is_some()); - choice!(scale_row, "render_scale", o.render_scale.is_some()); - choice!(codec_row, "codec", o.codec.is_some()); - choice!(compositor_row, "compositor", o.compositor.is_some()); - choice!(stats_row, "stats_verbosity", o.stats_verbosity.is_some()); - choice!(touch_row, "touch_mode", o.touch_mode.is_some()); - choice!(mouse_row, "mouse_mode", o.mouse_mode.is_some()); - choice!(surround_row, "audio_channels", o.audio_channels.is_some()); - choice!(pad_row, "gamepad", o.gamepad.is_some()); - toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some()); - toggle!(chroma_row, "enable_444", o.enable_444.is_some()); - toggle!( - fullscreen_row, - "fullscreen_on_stream", - o.fullscreen_on_stream.is_some() - ); - toggle!( - inhibit_row, - "inhibit_shortcuts", - o.inhibit_shortcuts.is_some() - ); - toggle!(invert_row, "invert_scroll", o.invert_scroll.is_some()); - toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some()); - { - let show = mark( - bitrate_row.upcast_ref(), - "bitrate_kbps", - o.bitrate_kbps.is_some(), - ); - let t = touched.clone(); - bitrate_row.connect_value_notify(move |_| { - t.mark("bitrate_kbps"); - if let Some(show) = &show { - show(); - } - }); - } - } - - // ---- Assemble the category pages (the Apple revamp's map) ---- - let general = page("General", "preferences-system-symbolic"); - // The scope switcher heads the first page — the one row that is always about which layer - // you are editing, not about the stream. - general.add(&scope_group( - &dialog, - inline, - &scope, - &catalog, - active.as_ref(), - &next_scope, - parent, - )); - let session_group = group("Session", ""); - session_group.add(&fullscreen_row); - // Auto-wake is a property of the host and this network, not of "Game vs Work" — it stays - // global in v1 (design §3, tier H/G). - if !profile_mode { - session_group.add(&wake_row); - } - let stats_group = group("Statistics", ""); - stats_group.add(stats_row.widget()); - general.add(&session_group); - general.add(&stats_group); - // The library browser is an app-level toggle for this device, not a per-profile one. - if !profile_mode { - let library_group = group("Library", ""); - library_group.add(&library_row); - general.add(&library_group); - } - - let display = page("Display", "video-display-symbolic"); - let resolution_group = group("Resolution", ""); - resolution_group.add(res_row.widget()); - resolution_group.add(hz_row.widget()); - let quality_group = group("Quality", ""); - quality_group.add(scale_row.widget()); - quality_group.add(&bitrate_row); - quality_group.add(codec_row.widget()); - quality_group.add(&hdr_row); - quality_group.add(&chroma_row); - // Decoder and GPU are facts about THIS device's hardware — never per profile (tier G). - if !profile_mode { - quality_group.add(decoder_row.widget()); - } - if let (Some(r), false) = (&gpu_row, profile_mode) { - quality_group.add(r.widget()); - } - // The one form-level note (deliberately not repeated on every row). - let output_group = group( - "Host output", - "Display changes apply from the next session.", - ); - output_group.add(compositor_row.widget()); - display.add(&resolution_group); - display.add(&quality_group); - display.add(&output_group); - - let input = page("Input", "input-keyboard-symbolic"); - let touch_group = group("Touch", ""); - touch_group.add(touch_row.widget()); - // Group titles are Pango markup — the ampersand must be an entity. - let kbm_group = group("Keyboard & mouse", ""); - kbm_group.add(mouse_row.widget()); - kbm_group.add(&inhibit_row); - kbm_group.add(&invert_row); - input.add(&touch_group); - input.add(&kbm_group); - - let audio = page("Audio", "audio-volume-high-symbolic"); - let audio_group = group("", "Applies from the next session."); - audio_group.add(surround_row.widget()); - // The speaker/mic endpoint pickers below are this device's audio routing (tier G) — they - // render only in the defaults scope; the surround + mic-uplink rows above are profileable. - - if let (Some(r), false) = (&speaker_row, profile_mode) { - audio_group.add(r.widget()); - } - audio_group.add(&mic_row); - if let (Some(r), false) = (&micdev_row, profile_mode) { - audio_group.add(r.widget()); - } - audio.add(&audio_group); - - let controllers = page("Controllers", "input-gaming-symbolic"); - let controllers_group = group("", ""); - // The detected-pad list (mirrors the Apple Controllers section): informational rows - // above the pickers, from the same snapshot that feeds the forwarding picker. It is - // about the hardware plugged into THIS device, so profile scope shows only the - // emulated-type picker below it. - if profile_mode { - // nothing — the pad inventory belongs to the device, not the profile - } else if pads.is_empty() { - let none = adw::ActionRow::builder() - .title("No controllers detected") - .css_classes(["dim-label"]) - .build(); - controllers_group.add(&none); - } else { - for p in &pads { - let row = adw::ActionRow::builder() - .title(&p.name) - .use_markup(false) - .build(); - if p.steam_virtual { - row.set_subtitle( - "Steam Input's virtual pad — Automatic skips it while a real pad is connected", - ); - } else { - row.set_subtitle(p.kind_label()); - } - row.add_prefix(>k::Image::from_icon_name("input-gaming-symbolic")); - controllers_group.add(&row); - } - } - if !profile_mode { - controllers_group.add(forward_row.widget()); - } - controllers_group.add(pad_row.widget()); - controllers.add(&controllers_group); - - dialog.add(&general); - dialog.add(&display); - dialog.add(&input); - dialog.add(&audio); - dialog.add(&controllers); - - dialog.connect_closed(move |_| { - // One reader for the rows, two destinations: the globals, or a profile's overrides. - // Sharing it is what keeps the two scopes from interpreting the same controls - // differently (the tri-state resolution row is the obvious trap). - let apply_rows = |s: &mut Settings| { - // Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit. - let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len()); - s.match_window = res_i == 1; - (s.width, s.height) = if res_i <= 1 { - (0, 0) - } else { - RESOLUTIONS[res_i - 1] - }; - s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; - s.render_scale = - RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)]; - s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; - // Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the - // session, hand-edited or written by another client): it displays as "Automatic", and - // writing that back would silently erase it just by opening + closing the dialog. - // Persist the row only when the user picked a non-Auto entry or the stored value was - // a listed one to begin with. - let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1); - if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) { - s.gamepad = GAMEPADS[pad_sel].to_string(); - } - s.touch_mode = - TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string(); - s.mouse_mode = - MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string(); - s.forward_pad = chosen_pin.borrow().clone(); - s.compositor = COMPOSITORS - [(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] - .to_string(); - s.decoder = - DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); - if let Some(r) = &gpu_row { - s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone(); - } - if let Some(r) = &speaker_row { - s.speaker_device = - speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone(); - } - if let Some(r) = &micdev_row { - s.mic_device = - micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone(); - } - s.set_stats_verbosity( - StatsVerbosity::ALL - [(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)], - ); - s.fullscreen_on_stream = fullscreen_row.is_active(); - s.auto_wake = wake_row.is_active(); - s.inhibit_shortcuts = inhibit_row.is_active(); - s.invert_scroll = invert_row.is_active(); - s.mic_enabled = mic_row.is_active(); - s.hdr_enabled = hdr_row.is_active(); - s.enable_444 = chroma_row.is_active(); - s.audio_channels = match surround_row.selected() { - 1 => 6, - 2 => 8, - _ => 2, - }; - s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string(); - s.library_enabled = library_row.is_active(); - }; - - match &active { - // Profile scope writes the touched rows into the catalog and leaves the globals - // exactly as they were — the point of the whole feature. - Some(active) => { - let mut values = seed.clone(); - apply_rows(&mut values); - commit_profile(active, &touched, &cleared.borrow(), &values); - } - None => { - let mut s = settings.borrow_mut(); - apply_rows(&mut s); - s.save(); - } - } - // A scope switch closed this dialog to commit first; now re-open in the new scope. - if let Some(next) = next_scope.borrow_mut().take() { - on_scope(next); - } - on_closed(); - }); - dialog.present(Some(parent)); - dialog -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Depth-first search for an [`adw::ActionRow`] with the given title. - fn find_action_row(root: >k::Widget, title: &str) -> Option { - if let Some(row) = root.downcast_ref::() { - if row.title() == title { - return Some(row.clone()); - } - } - let mut child = root.first_child(); - while let Some(c) = child { - if let Some(hit) = find_action_row(&c, title) { - return Some(hit); - } - child = c.next_sibling(); - } - None - } - - fn pump() { - let ctx = gtk::glib::MainContext::default(); - while ctx.iteration(false) {} - } - - /// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test - /// its own thread, so the display tests can't be split). Gamescope mode: activating the - /// row pushes the in-window selection subpage; activating an option updates the - /// selection + suffix label, fires the change callback, and pops the subpage. Combo - /// mode: cell sync + change callback. Needs a display — run manually with - /// `cargo test -p punktfunk-client-linux -- --ignored` on a session box. - #[test] - #[ignore = "needs a Wayland/X display"] - fn choice_row_modes() { - assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display"); - let win = adw::Window::new(); - let dialog = adw::PreferencesDialog::new(); - let page = adw::PreferencesPage::new(); - let group = adw::PreferencesGroup::new(); - let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]); - group.add(row.widget()); - page.add(&group); - dialog.add(&page); - let fired = Rc::new(Cell::new(u32::MAX)); - { - let f = fired.clone(); - row.connect_changed(move |i| f.set(i)); - } - win.present(); - dialog.present(Some(&win)); - pump(); - - // Suffix label reflects the seed. - assert_eq!(row.value_label.as_ref().unwrap().text(), "A"); - - // Row activation → subpage with the options list. - row.widget() - .downcast_ref::() - .unwrap() - .emit_by_name::<()>("activated", &[]); - pump(); - let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing"); - - // Option activation → state + label + callback, subpage popped. - opt_b.emit_by_name::<()>("activated", &[]); - pump(); - assert_eq!(row.selected(), 1); - assert_eq!(fired.get(), 1); - assert_eq!(row.value_label.as_ref().unwrap().text(), "B"); - - // The dynamic-caption hook drives the same subtitle both row shapes expose. - set_row_subtitle(row.widget(), "swapped"); - assert_eq!( - row.widget() - .downcast_ref::() - .unwrap() - .subtitle() - .as_deref(), - Some("swapped") - ); - - // Re-activating shows the check on the new selection (fresh subpage each time). - row.widget() - .downcast_ref::() - .unwrap() - .emit_by_name::<()>("activated", &[]); - pump(); - assert!(find_action_row(dialog.upcast_ref(), "B").is_some()); - - // Desktop (ComboRow) mode: cell sync + change callback on selection change. - let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]); - combo.set_selected(1); - assert_eq!(combo.selected(), 1); - let combo_fired = Rc::new(Cell::new(u32::MAX)); - { - let f = combo_fired.clone(); - combo.connect_changed(move |i| f.set(i)); - } - combo.set_selected(0); - assert_eq!(combo.selected(), 0); - assert_eq!(combo_fired.get(), 0); - // ComboRow derives from ActionRow, so the caption hook reaches it too. - set_row_subtitle(combo.widget(), "combo caption"); - assert_eq!( - combo - .widget() - .downcast_ref::() - .unwrap() - .subtitle() - .as_deref(), - Some("combo caption") - ); - } -} diff --git a/clients/session/src/ui_trust.rs b/clients/session/src/ui_trust.rs deleted file mode 100644 index f078462f..00000000 --- a/clients/session/src/ui_trust.rs +++ /dev/null @@ -1,350 +0,0 @@ -//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and -//! delegated (request-access) approval. The trust GATE itself (rules 1–3) lives in -//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens, -//! each resolving into typed [`AppMsg`]s. - -use crate::app::{AppModel, AppMsg}; -use crate::spawn::{CancelHandle, SpawnOpts}; -use crate::trust; -use crate::ui_hosts::ConnectRequest; -use adw::prelude::*; -use gtk::glib; -use pf_client_core::orchestrate::{WakeOutcome, WakeWait}; -use relm4::prelude::*; - -/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a -/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is -/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few -/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved -/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog -/// lets the user cancel. -/// -/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported -/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the -/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate. -pub fn wake_and_connect( - window: &adw::ApplicationWindow, - sender: &ComponentSender, - req: ConnectRequest, -) { - let cancel = std::rc::Rc::new(std::cell::Cell::new(false)); - let waiting = adw::AlertDialog::new( - Some("Waking Host"), - Some(&format!( - "Sent a wake signal to “{}”. Waiting for it to come online…", - req.name - )), - ); - waiting.add_responses(&[("cancel", "Cancel")]); - waiting.set_close_response("cancel"); - { - let cancel = cancel.clone(); - waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true)); - } - waiting.present(Some(window)); - - let sender = sender.clone(); - glib::spawn_future_local(async move { - use std::time::Duration; - let events = crate::discovery::browse(); - let mut wait = WakeWait::new(); - loop { - if cancel.get() { - waiting.close(); - return; - } - // Drain resolved adverts; a match (fingerprint, else addr:port) means it is up, - // and carries the address it came back on. - let mut seen: Option<(String, u16)> = None; - while let Ok(ev) = events.try_recv() { - let crate::discovery::DiscoveryEvent::Resolved(h) = ev else { - continue; - }; - let matched = match &req.fp_hex { - Some(fp) => !h.fp_hex.is_empty() && &h.fp_hex == fp, - None => h.addr == req.addr && h.port == req.port, - }; - if matched { - seen = Some((h.addr, h.port)); - } - } - let tick = wait.tick(seen.is_some()); - if tick.send_packet { - crate::wol::wake(&req.mac, req.addr.parse().ok()); - } - match tick.outcome { - Some(WakeOutcome::Online) => { - waiting.close(); - let mut req = req.clone(); - // Re-key on a new DHCP lease so this + future connects dial the - // live address. - if let Some((addr, port)) = - seen.filter(|(a, p)| *a != req.addr || *p != req.port) - { - if let Some(fp) = &req.fp_hex { - trust::rekey_addr(fp, &addr, port); - } - req.addr = addr; - req.port = port; - } - sender.input(AppMsg::Connect(req)); - return; - } - Some(WakeOutcome::TimedOut) => { - waiting.close(); - sender.input(AppMsg::Toast(format!( - "Couldn't reach “{}” — is it powered and on the network?", - req.name - ))); - return; - } - None => {} - } - glib::timeout_future(Duration::from_secs(1)).await; - } - }); -} - -/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines, -/// far easier to compare against the host's log than one 64-char run. -fn grouped_fingerprint(fp: &str) -> String { - let groups: Vec<&str> = fp - .as_bytes() - .chunks(4) - .map(|c| std::str::from_utf8(c).unwrap_or("")) - .collect(); - groups - .chunks(8) - .map(|line| line.join(" ")) - .collect::>() - .join("\n") -} - -/// First contact with a discovered host that opted into TOFU: show the advertised -/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away. -/// Trusting starts a session pinned to the advertised fp; it persists once the child -/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`). -pub fn tofu_dialog( - window: &adw::ApplicationWindow, - sender: &ComponentSender, - req: ConnectRequest, -) { - let fp = req.fp_hex.clone().unwrap_or_default(); - let dialog = adw::AlertDialog::new( - Some("New Host"), - Some(&format!( - "{} at {}:{}\n\nPairing with a PIN verifies the certificate fingerprint below; \ - trusting accepts it as-is.", - req.name, req.addr, req.port - )), - ); - let fp_label = gtk::Label::new(Some(&grouped_fingerprint(&fp))); - fp_label.add_css_class("monospace"); - fp_label.set_selectable(true); - fp_label.set_justify(gtk::Justification::Center); - fp_label.set_halign(gtk::Align::Center); - dialog.set_extra_child(Some(&fp_label)); - dialog.add_responses(&[ - ("cancel", "Cancel"), - ("pair", "Pair with PIN…"), - ("trust", "Trust & Connect"), - ]); - dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("trust")); - dialog.set_close_response("cancel"); - let sender = sender.clone(); - dialog.connect_response(None, move |_, response| match response { - "trust" => sender.input(AppMsg::StartSession { - req: req.clone(), - fp_hex: fp.clone(), - tofu: true, - opts: SpawnOpts::default(), - }), - "pair" => sender.input(AppMsg::Pair(req.clone())), - _ => {} - }); - dialog.present(Some(window)); -} - -/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge -/// of it pins the host's certificate (and registers ours) with no offline-guessable -/// transcript. Success persists the host as paired and connects. -pub fn pin_dialog( - window: &adw::ApplicationWindow, - sender: &ComponentSender, - identity: (String, String), - req: ConnectRequest, -) { - let entry = gtk::Entry::builder() - .input_purpose(gtk::InputPurpose::Digits) - .placeholder_text("4-digit PIN shown by the host") - .activates_default(true) - .build(); - // The label the HOST stores this client under — prefilled with the hostname. - let name_entry = gtk::Entry::builder() - .text(glib::host_name().as_str()) - .activates_default(true) - .build(); - let name_caption = gtk::Label::new(Some("This device")); - name_caption.add_css_class("caption"); - name_caption.add_css_class("dim-label"); - name_caption.set_halign(gtk::Align::Start); - let fields = gtk::Box::new(gtk::Orientation::Vertical, 6); - fields.append(&name_caption); - fields.append(&name_entry); - let pin_caption = gtk::Label::new(Some("PIN")); - pin_caption.add_css_class("caption"); - pin_caption.add_css_class("dim-label"); - pin_caption.set_halign(gtk::Align::Start); - fields.append(&pin_caption); - fields.append(&entry); - let dialog = adw::AlertDialog::new( - Some("Pair with PIN"), - Some(&format!( - "Arm pairing on {} (console or web UI), then enter the PIN it displays.", - req.name - )), - ); - dialog.set_extra_child(Some(&fields)); - dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]); - dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("pair")); - dialog.set_close_response("cancel"); - let sender = sender.clone(); - dialog.connect_response(Some("pair"), move |_, _| { - let pin = entry.text().to_string(); - let req = req.clone(); - let identity = identity.clone(); - let sender = sender.clone(); - let (tx, rx) = async_channel::bounded::>(1); - let device = name_entry.text().trim().to_string(); - let name = if device.is_empty() { - glib::host_name().to_string() - } else { - device - }; - let (host, port) = (req.addr.clone(), req.port); - std::thread::spawn(move || { - // Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host - // rejection) — never blame the PIN for a dead network path. - let result = trust::pair_with_host(&host, port, &identity, &pin, &name) - .map_err(|e| trust::pair_error_message(&e)); - let _ = tx.send_blocking(result); - }); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(fp)) => { - let fp_hex = trust::hex(&fp); - trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true); - sender.input(AppMsg::Toast("Paired — connecting…".into())); - sender.input(AppMsg::StartSession { - req, - fp_hex, - tofu: false, - opts: SpawnOpts::default(), - }); - } - Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)), - Err(_) => {} - } - }); - }); - dialog.present(Some(window)); -} - -/// A fresh host that requires pairing: "Request access" (connect and wait for the -/// operator to click Approve in the host's console — delegated approval) or the PIN -/// ceremony. -pub fn approval_dialog( - window: &adw::ApplicationWindow, - sender: &ComponentSender, - waiting_slot: std::rc::Rc>>, - req: ConnectRequest, -) { - let dialog = adw::AlertDialog::new( - Some("Pairing Required"), - Some(&format!( - "{} requires pairing.\n\nRequest access and approve this device in the host's console \ - (or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.", - req.name - )), - ); - dialog.add_responses(&[ - ("cancel", "Cancel"), - ("pin", "Use a PIN instead…"), - ("request", "Request Access"), - ]); - dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("request")); - dialog.set_close_response("cancel"); - let parent = window.clone(); - let window = window.clone(); - let sender = sender.clone(); - dialog.connect_response(None, move |_, response| match response { - "request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()), - "pin" => sender.input(AppMsg::Pair(req.clone())), - _ => {} - }); - dialog.present(Some(&parent)); -} - -/// The no-PIN "request access" flow: the session child opens an identified connect the -/// host PARKS until the operator approves it in the console; a cancelable "waiting" -/// dialog covers the wait. On approval the same connection is admitted and the host is -/// saved as paired. Cancel kills the child (the only abort a parked connect has). -/// -/// The pinned fingerprint is the advertised one for a discovered host (defence against -/// an impostor while we wait). A manually-typed host has no advertised fingerprint — -/// the session binary refuses pinless connects, so this path requires the advert; a -/// manual entry's Request Access rides the same flow only when a fingerprint exists. -fn request_access( - window: &adw::ApplicationWindow, - sender: &ComponentSender, - waiting_slot: std::rc::Rc>>, - req: ConnectRequest, -) { - let Some(fp_hex) = req.fp_hex.clone() else { - // No fingerprint to pin (manual entry): the strict child can't do a - // trust-on-approval connect — route to the PIN ceremony instead. - sender.input(AppMsg::Toast( - "No advertised identity for this host — pair with a PIN instead.".into(), - )); - sender.input(AppMsg::Pair(req)); - return; - }; - let cancel = CancelHandle::default(); - let waiting = adw::AlertDialog::new( - Some("Waiting for Approval"), - Some(&format!( - "Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \ - connects automatically once you approve it.", - glib::host_name(), - req.name - )), - ); - waiting.add_responses(&[("cancel", "Cancel")]); - waiting.set_close_response("cancel"); - { - let sender = sender.clone(); - let cancel = cancel.clone(); - waiting.connect_response(Some("cancel"), move |_, _| { - cancel.kill(); - sender.input(AppMsg::CancelPending); - }); - } - waiting.present(Some(window)); - *waiting_slot.borrow_mut() = Some(waiting); - - sender.input(AppMsg::StartSession { - req, - fp_hex, - tofu: false, - opts: SpawnOpts { - // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow - // operator approval still lands on this connection. - connect_timeout_secs: Some(185), - persist_paired: true, - cancel: Some(cancel), - }, - }); -} diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index e6cbf136..9ba9daf5 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -287,7 +287,7 @@ fn connect_spawn( ss.call(Screen::Stream); } SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line, - SpawnEvent::Exited { error, ended } => { + SpawnEvent::Exited { error, ended, code } => { match error { Some((msg, true)) => { // Pinned-fingerprint mismatch / pairing required → re-pair via @@ -311,8 +311,14 @@ fn connect_spawn( } // `ended` = the host ended the session (banner); a clean exit // (user closed the stream window / Disconnect) returns silently. + // A child that said nothing AND failed gets the exit code, so the + // return to the host list is never unexplained. None => { - st.call(ended.unwrap_or_default()); + st.call( + ended + .or_else(|| crate::spawn::silent_exit_banner(code)) + .unwrap_or_default(), + ); ss.call(Screen::Hosts); } } @@ -367,11 +373,18 @@ pub(crate) fn open_console( ss.call(Screen::Stream); } SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line, - SpawnEvent::Exited { error, ended } => { + SpawnEvent::Exited { error, ended, code } => { 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()); + // a failed start surfaces its error line, or the exit code when it + // died without producing one. + st.call( + error + .map(|(msg, _)| msg) + .or(ended) + .or_else(|| crate::spawn::silent_exit_banner(code)) + .unwrap_or_default(), + ); ss.call(Screen::Hosts); } } diff --git a/clients/windows/src/spawn.rs b/clients/windows/src/spawn.rs index 58fe3078..cf92eb68 100644 --- a/clients/windows/src/spawn.rs +++ b/clients/windows/src/spawn.rs @@ -18,11 +18,16 @@ pub(crate) enum SpawnEvent { /// One `stats:` line, already human-formatted by the session (per 1 s window). Stats(String), /// The child exited (stdout EOF + reap; a kill lands here too). `error`/`ended` - /// carry the contract lines seen on the way out, when any (the exit code is logged - /// by the reader; routing keys off the lines, which say strictly more). + /// carry the contract lines seen on the way out, when any — routing keys off those, + /// which say strictly more than a number. `code` is the process exit status (-1 = no + /// code, i.e. killed) and exists for the case where there were NO lines at all: a + /// child that dies before it can speak the contract would otherwise be indistinguishable + /// from a clean user-initiated quit, and the shell would bounce to the host list with a + /// blank banner. That is exactly how the 0.22.0 session-binary regression presented. Exited { error: Option<(String, bool)>, ended: Option, + code: i32, }, } @@ -75,6 +80,19 @@ fn parse_line(line: &str) -> Option { None } +/// The banner for a child that exited having said NOTHING on stdout — no `ready`, no +/// `error`, no `ended`. `None` keeps the silent return the UI has always given a clean +/// quit: code 0 is the user closing the stream window, and -1 is our own Disconnect/Cancel +/// kill (no exit code). Anything else is the session dying before it could speak its +/// contract — a missing runtime DLL, a crash, or the wrong binary sitting next to the +/// shell — and reporting the code is the difference between a diagnosable failure and a +/// connect that silently drops back to the host list. +pub(crate) fn silent_exit_banner(code: i32) -> Option { + (code != 0 && code != -1).then(|| { + format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.") + }) +} + /// The session binary: installed next to the shell (the MSIX layout and dev /// `target\…` runs both land on the sibling), else `PATH`. pub(crate) fn session_binary() -> std::path::PathBuf { @@ -220,7 +238,7 @@ fn spawn_with( .and_then(|s| s.code()) .unwrap_or(-1); tracing::info!(code, "session binary exited"); - on_event(SpawnEvent::Exited { error, ended }); + on_event(SpawnEvent::Exited { error, ended, code }); }) .map_err(|e| format!("session reader thread: {e}"))?; Ok(()) @@ -262,4 +280,17 @@ mod tests { assert!(parse_line("").is_none()); assert!(parse_line("{\"other\":1}").is_none()); } + + #[test] + fn a_silent_failing_exit_is_never_blank() { + // Clean quit (stream window closed) and our own kill stay silent. + assert!(silent_exit_banner(0).is_none()); + assert!(silent_exit_banner(-1).is_none()); + // A child that died without speaking the contract names its code — the 0.22.0 + // regression (a stub session binary exiting 2) showed as a blank bounce to the + // host list precisely because nothing filled this in. + let banner = silent_exit_banner(2).expect("failing exit must say something"); + assert!(banner.contains('2'), "{banner}"); + assert!(silent_exit_banner(101).is_some()); + } }