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());
+ }
}
|