diff --git a/Cargo.lock b/Cargo.lock index 6a517e2c..2219fceb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1585,6 +1585,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "glib-build-tools" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9871f38b67853c358b8190f77b9f878eb27d933a950f1045b244c4559a9f5f0" + [[package]] name = "glib-macros" version = "0.22.6" @@ -3313,6 +3319,7 @@ version = "0.21.0" dependencies = [ "anyhow", "async-channel", + "glib-build-tools", "gtk4", "libadwaita", "pf-client-core", @@ -3328,6 +3335,7 @@ name = "punktfunk-client-session" version = "0.21.0" dependencies = [ "anyhow", + "glib-build-tools", "pf-client-core", "pf-console-ui", "pf-presenter", diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 6e7eb3a7..fc664cc9 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -243,6 +243,7 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not "fp_hex": h.fp_hex, "paired": h.paired, "mac": h.mac, + "os": h.os, "last_used": h.last_used, "clipboard_sync": h.clipboard_sync, "profile": h.profile_id.as_ref() diff --git a/clients/linux/Cargo.toml b/clients/linux/Cargo.toml index 0a2128d7..1aae33b7 100644 --- a/clients/linux/Cargo.toml +++ b/clients/linux/Cargo.toml @@ -33,5 +33,10 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } relm4 = { version = "0.11", features = ["libadwaita"] } +# 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/linux/build.rs b/clients/linux/build.rs new file mode 100644 index 00000000..36b8a1ab --- /dev/null +++ b/clients/linux/build.rs @@ -0,0 +1,15 @@ +//! 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!`. + +fn main() { + // Host cfg gate mirrors this crate's `#[cfg(target_os = "linux")]` modules: on any other + // host the crate compiles to an empty stub and `glib-compile-resources` may not exist. + #[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", + ); + } +} diff --git a/clients/linux/data/icons/scalable/actions/pf-os-apple-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-apple-symbolic.svg new file mode 100644 index 00000000..2af51e1a --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-apple-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-os-arch-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-arch-symbolic.svg new file mode 100644 index 00000000..458911f1 --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-arch-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-os-debian-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-debian-symbolic.svg new file mode 100644 index 00000000..9b82e206 --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-debian-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-os-fedora-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-fedora-symbolic.svg new file mode 100644 index 00000000..428abe6a --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-fedora-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-os-linux-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-linux-symbolic.svg new file mode 100644 index 00000000..0f806c24 --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-linux-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-os-nixos-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-nixos-symbolic.svg new file mode 100644 index 00000000..cc34d519 --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-nixos-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-os-opensuse-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-opensuse-symbolic.svg new file mode 100644 index 00000000..e52617ec --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-opensuse-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-os-steam-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-steam-symbolic.svg new file mode 100644 index 00000000..db35d17b --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-steam-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-os-ubuntu-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-ubuntu-symbolic.svg new file mode 100644 index 00000000..7bb9ce4f --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-ubuntu-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-os-windows-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-os-windows-symbolic.svg new file mode 100644 index 00000000..f7f972bf --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-os-windows-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/resources.gresource.xml b/clients/linux/data/resources.gresource.xml new file mode 100644 index 00000000..660e71e4 --- /dev/null +++ b/clients/linux/data/resources.gresource.xml @@ -0,0 +1,18 @@ + + + + + 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/linux/src/app.rs b/clients/linux/src/app.rs index e1c3f5e1..dd877a55 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -179,6 +179,7 @@ impl SimpleComponent for AppModel { } }; 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). @@ -937,6 +938,19 @@ pub fn run() -> glib::ExitCode { 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); diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 7eea44d1..0c7feeff 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -283,6 +283,7 @@ pub fn headless_list_hosts() -> glib::ExitCode { "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), @@ -508,6 +509,7 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) { 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 diff --git a/clients/linux/src/main.rs b/clients/linux/src/main.rs index 5c8d37b0..f5f04a89 100644 --- a/clients/linux/src/main.rs +++ b/clients/linux/src/main.rs @@ -8,7 +8,7 @@ // 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, trust, video, wol}; +pub use pf_client_core::{discovery, gamepad, library, os, trust, video, wol}; #[cfg(target_os = "linux")] mod app; diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 597c6e93..ac538172 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -217,6 +217,15 @@ impl relm4::factory::FactoryComponent for HostCard { 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"); @@ -573,6 +582,28 @@ const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(12); /// for this — without it every profile is the same grey, and telling them apart across a grid /// at a glance is the whole reason the chip exists. No colour set keeps the neutral pill, so /// the palette stays opt-in. +/// 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) +} + fn profile_pill(p: &Profile) -> gtk::Widget { let label = gtk::Label::new(Some(&p.name)); label.add_css_class("pf-pill"); @@ -1036,6 +1067,14 @@ impl HostsPage { { 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 { diff --git a/clients/session/Cargo.toml b/clients/session/Cargo.toml index 1d8567c8..2f19a15f 100644 --- a/clients/session/Cargo.toml +++ b/clients/session/Cargo.toml @@ -68,5 +68,10 @@ serde_json = "1" [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 17c1d63c..1bd2e46c 100644 --- a/clients/session/build.rs +++ b/clients/session/build.rs @@ -4,6 +4,18 @@ //! 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", + ); + } + // cfg(windows) is the HOST (skips Linux/macOS builds of this cross-platform binary); // CARGO_CFG_WINDOWS is the TARGET (x64 and cross-compiled ARM64 both pass). #[cfg(windows)] 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 new file mode 100644 index 00000000..2af51e1a --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-apple-symbolic.svg @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..458911f1 --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-arch-symbolic.svg @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..9b82e206 --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-debian-symbolic.svg @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..428abe6a --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-fedora-symbolic.svg @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..0f806c24 --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-linux-symbolic.svg @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..cc34d519 --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-nixos-symbolic.svg @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..e52617ec --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-opensuse-symbolic.svg @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..db35d17b --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-steam-symbolic.svg @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..7bb9ce4f --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-ubuntu-symbolic.svg @@ -0,0 +1,2 @@ + + 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 new file mode 100644 index 00000000..f7f972bf --- /dev/null +++ b/clients/session/data/icons/scalable/actions/pf-os-windows-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/session/data/resources.gresource.xml b/clients/session/data/resources.gresource.xml new file mode 100644 index 00000000..660e71e4 --- /dev/null +++ b/clients/session/data/resources.gresource.xml @@ -0,0 +1,18 @@ + + + + + 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 index e1c3f5e1..dd877a55 100644 --- a/clients/session/src/app.rs +++ b/clients/session/src/app.rs @@ -179,6 +179,7 @@ impl SimpleComponent for AppModel { } }; 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). @@ -937,6 +938,19 @@ pub fn run() -> glib::ExitCode { 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); diff --git a/clients/session/src/cli.rs b/clients/session/src/cli.rs index 89889fa5..4946aa56 100644 --- a/clients/session/src/cli.rs +++ b/clients/session/src/cli.rs @@ -283,6 +283,7 @@ pub fn headless_list_hosts() -> glib::ExitCode { "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), @@ -508,6 +509,7 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) { 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 diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 0c24fc32..51aea1a2 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -78,6 +78,7 @@ pub fn run(target: Option<&str>) -> u8 { .unwrap_or(library::DEFAULT_MGMT_PORT), can_wake: false, last_used: k.and_then(|h| h.last_used), + os: k.map(|h| h.os.clone()).unwrap_or_default(), }; let label = row.name.clone(); if k.is_none() { @@ -295,6 +296,7 @@ fn fake_host_row() -> HostRow { mgmt_port: library::DEFAULT_MGMT_PORT, can_wake: false, last_used: None, + os: "linux/arch/steamos".into(), } } @@ -570,6 +572,10 @@ impl ServiceState { .unwrap_or(library::DEFAULT_MGMT_PORT), can_wake: !online && !h.mac.is_empty(), last_used: h.last_used, + os: advert + .filter(|d| !d.os.is_empty()) + .map(|d| d.os.clone()) + .unwrap_or_else(|| h.os.clone()), } }) .collect(); @@ -600,6 +606,7 @@ impl ServiceState { mgmt_port: d.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT), can_wake: false, last_used: None, + os: d.os.clone(), }) .collect(); extra.sort_by(|a, b| a.name.cmp(&b.name)); diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 5c8d37b0..f5f04a89 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -8,7 +8,7 @@ // 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, trust, video, wol}; +pub use pf_client_core::{discovery, gamepad, library, os, trust, video, wol}; #[cfg(target_os = "linux")] mod app; diff --git a/clients/session/src/ui_hosts.rs b/clients/session/src/ui_hosts.rs index 4a425f51..6ac134ff 100644 --- a/clients/session/src/ui_hosts.rs +++ b/clients/session/src/ui_hosts.rs @@ -216,6 +216,15 @@ impl relm4::factory::FactoryComponent for HostCard { 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"); @@ -579,6 +588,28 @@ impl relm4::factory::FactoryComponent for HostCard { /// 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); @@ -1015,6 +1046,14 @@ impl HostsPage { { 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 { diff --git a/clients/windows/assets/os/apple.png b/clients/windows/assets/os/apple.png new file mode 100644 index 00000000..f0ab8f07 Binary files /dev/null and b/clients/windows/assets/os/apple.png differ diff --git a/clients/windows/assets/os/arch.png b/clients/windows/assets/os/arch.png new file mode 100644 index 00000000..f656b3b4 Binary files /dev/null and b/clients/windows/assets/os/arch.png differ diff --git a/clients/windows/assets/os/debian.png b/clients/windows/assets/os/debian.png new file mode 100644 index 00000000..6781b555 Binary files /dev/null and b/clients/windows/assets/os/debian.png differ diff --git a/clients/windows/assets/os/fedora.png b/clients/windows/assets/os/fedora.png new file mode 100644 index 00000000..9871c2c9 Binary files /dev/null and b/clients/windows/assets/os/fedora.png differ diff --git a/clients/windows/assets/os/linux.png b/clients/windows/assets/os/linux.png new file mode 100644 index 00000000..6bbd7b0b Binary files /dev/null and b/clients/windows/assets/os/linux.png differ diff --git a/clients/windows/assets/os/nixos.png b/clients/windows/assets/os/nixos.png new file mode 100644 index 00000000..3d81fbe3 Binary files /dev/null and b/clients/windows/assets/os/nixos.png differ diff --git a/clients/windows/assets/os/opensuse.png b/clients/windows/assets/os/opensuse.png new file mode 100644 index 00000000..5709ea5c Binary files /dev/null and b/clients/windows/assets/os/opensuse.png differ diff --git a/clients/windows/assets/os/steam.png b/clients/windows/assets/os/steam.png new file mode 100644 index 00000000..523baaba Binary files /dev/null and b/clients/windows/assets/os/steam.png differ diff --git a/clients/windows/assets/os/ubuntu.png b/clients/windows/assets/os/ubuntu.png new file mode 100644 index 00000000..c3823a17 Binary files /dev/null and b/clients/windows/assets/os/ubuntu.png differ diff --git a/clients/windows/assets/os/windows.png b/clients/windows/assets/os/windows.png new file mode 100644 index 00000000..3da00a82 Binary files /dev/null and b/clients/windows/assets/os/windows.png differ diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index 452ffc75..6ec2a6ea 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -167,9 +167,10 @@ pub(crate) struct Hover { pub(crate) set: AsyncSetState>, } -/// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip. -fn status_row(online: Option, badge: &str, kind: Pill) -> Element { - status_row_with(online, badge, kind, None) +/// The status row at the bottom of a tile: the host's OS mark (when advertised), presence +/// dot + Online/Offline, plus the trust chip. +fn status_row(os: &str, online: Option, badge: &str, kind: Pill) -> Element { + status_row_with(os, online, badge, kind, None) } /// [`status_row`] plus the profile chip: what a plain click on THIS tile will use — its own @@ -177,12 +178,26 @@ fn status_row(online: Option, badge: &str, kind: Pill) -> Element { /// was deleted shows nothing and resolves as the defaults, which is what will happen on /// connect (design §6). fn status_row_with( + os: &str, online: Option, badge: &str, kind: Pill, profile: Option<(&str, Option)>, ) -> Element { let mut items: Vec = Vec::new(); + // The OS mark leads the row; nothing at all for an older host that doesn't advertise + // one, so those tiles render exactly as they always did. Raster at 16px from the + // materialized cache (reactor has no vector element); the raw chain is the tooltip. + if let Some(uri) = super::os_icons::uri(os) { + items.push( + Image::new_with_uri(uri) + .width(16.0) + .height(16.0) + .tooltip(os) + .vertical_alignment(VerticalAlignment::Center) + .into(), + ); + } if let Some(online) = online { items.push( presence_dot(online) @@ -663,6 +678,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { }) { crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac); } + // Same for its OS chain — the tile's mark then survives the host going offline. + if let Some(a) = hosts.iter().find(|h| { + (h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty() + }) { + crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os); + } let can_wake = !online && !k.mac.is_empty(); let menu = { let (svc, target) = (props.svc.clone(), target.clone()); @@ -786,6 +807,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { &k.name, &format!("{}:{}", k.addr, k.port), status_row_with( + &k.os, Some(online), if k.paired { "Paired" } else { "Trusted" }, if k.paired { Pill::Good } else { Pill::Info }, @@ -829,6 +851,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { &k.name, &format!("{}:{}", k.addr, k.port), status_row_with( + &k.os, Some(online), if k.paired { "Paired" } else { "Trusted" }, if k.paired { Pill::Good } else { Pill::Info }, @@ -893,7 +916,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { &hover, &h.name, &format!("{}:{}", h.addr, h.port), - status_row(None, badge, kind), + status_row(&h.os, None, badge, kind), None, Some(Box::new(move || initiate(&ctx2, target.clone(), &ss, &st))), )); diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index e0e8c07f..dde744ad 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -25,6 +25,7 @@ mod help; mod hosts; mod library; mod licenses; +mod os_icons; mod pair; mod settings; mod speed; @@ -152,6 +153,8 @@ pub struct AppCtx { } pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> { + // The host tiles' OS marks load as file:/// URIs — put the embedded PNGs on disk first. + os_icons::install(); let ctx = Arc::new(AppCtx { identity, settings: Mutex::new(Settings::load()), diff --git a/clients/windows/src/app/os_icons.rs b/clients/windows/src/app/os_icons.rs new file mode 100644 index 00000000..9a3390ff --- /dev/null +++ b/clients/windows/src/app/os_icons.rs @@ -0,0 +1,62 @@ +//! The host tiles' OS marks. Reactor's `ImageSource` is `file:///`-URI raster only (no +//! vector element, no icon font with brand glyphs), so the monochrome PNGs under +//! `assets/os/` (mid-gray — legible on both WinUI themes; derived from the +//! `assets/os-icons` masters, see that README for provenance/licensing) are embedded in +//! the exe and materialized once into `%LOCALAPPDATA%\punktfunk\os-icons\` — the same +//! disk-cache-to-URI pattern as the library's poster art. + +use std::path::PathBuf; +use std::sync::OnceLock; + +/// Embedded PNG per icon token, most-generic set the chain walk can land on. A distro +/// without its own mark (Bazzite, CachyOS, ...) degrades to its family's and finally Tux. +const ICONS: &[(&str, &[u8])] = &[ + ("windows", include_bytes!("../../assets/os/windows.png")), + ("apple", include_bytes!("../../assets/os/apple.png")), + ("linux", include_bytes!("../../assets/os/linux.png")), + ("steam", include_bytes!("../../assets/os/steam.png")), + ("ubuntu", include_bytes!("../../assets/os/ubuntu.png")), + ("fedora", include_bytes!("../../assets/os/fedora.png")), + ("arch", include_bytes!("../../assets/os/arch.png")), + ("debian", include_bytes!("../../assets/os/debian.png")), + ("nixos", include_bytes!("../../assets/os/nixos.png")), + ("opensuse", include_bytes!("../../assets/os/opensuse.png")), +]; + +fn dir() -> Option { + let base = std::env::var_os("LOCALAPPDATA")?; + Some(PathBuf::from(base).join("punktfunk").join("os-icons")) +} + +/// Materialize the embedded PNGs to disk (idempotent; size mismatch rewrites, so an icon +/// refresh in a newer build lands). Called once at GUI startup, before any tile renders. +pub fn install() { + let Some(dir) = dir() else { return }; + if std::fs::create_dir_all(&dir).is_err() { + return; // tiles just render without the mark + } + for (token, bytes) in ICONS { + let p = dir.join(format!("{token}.png")); + let fresh = std::fs::metadata(&p) + .map(|m| m.len() != bytes.len() as u64) + .unwrap_or(true); + if fresh { + let _ = std::fs::write(&p, bytes); + } + } +} + +/// The `file:///` URI of the mark for an OS-identity chain: walk most-specific-first +/// (pf-client-core's shared order/aliases) and take the first token we ship art for. +/// `None` (no image element at all) when the host doesn't advertise a chain or nothing +/// in it is recognized — the tile then renders exactly as it did before the field. +pub fn uri(chain: &str) -> Option { + static DIR: OnceLock> = OnceLock::new(); + let dir = DIR.get_or_init(dir).as_ref()?; + let token = pf_client_core::os::os_icon_tokens(chain) + .into_iter() + .find(|t| ICONS.iter().any(|(name, _)| name == t))?; + let p = dir.join(format!("{token}.png")); + p.exists() + .then(|| format!("file:///{}", p.display().to_string().replace('\\', "/"))) +} diff --git a/clients/windows/src/discovery.rs b/clients/windows/src/discovery.rs index 784bc66c..c10ab505 100644 --- a/clients/windows/src/discovery.rs +++ b/clients/windows/src/discovery.rs @@ -18,6 +18,10 @@ pub struct DiscoveredHost { /// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the /// hosts page persists onto the matching saved host so it can wake it later. Empty if absent. pub mac: Vec, + /// The host's OS-identity chain from the mDNS `os` TXT (`windows` | `macos` | + /// `linux[/][/]`), sanitized — drives the host tile's OS mark and is + /// persisted like `mac`. Empty if absent (older host). + pub os: String, } /// Browse continuously for the app's lifetime. The thread exits when the receiver is @@ -71,6 +75,7 @@ pub fn browse() -> async_channel::Receiver { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(), + os: pf_client_core::os::sanitize_os(&val("os")), }; if tx.send_blocking(host).is_err() { break; // UI gone — stop browsing diff --git a/clients/windows/src/trust.rs b/clients/windows/src/trust.rs index 5563907b..e16463c9 100644 --- a/clients/windows/src/trust.rs +++ b/clients/windows/src/trust.rs @@ -8,6 +8,6 @@ //! still load via a serde alias in core. pub use pf_client_core::trust::{ - hex, learn_mac, load_or_create_identity, pair_error_message, parse_hex32, KnownHost, + hex, learn_mac, learn_os, load_or_create_identity, pair_error_message, parse_hex32, KnownHost, KnownHosts, Settings, }; diff --git a/crates/pf-client-core/src/discovery.rs b/crates/pf-client-core/src/discovery.rs index 6aeccaa5..fdeb8fbe 100644 --- a/crates/pf-client-core/src/discovery.rs +++ b/crates/pf-client-core/src/discovery.rs @@ -25,6 +25,10 @@ pub struct DiscoveredHost { /// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the /// hosts page persists onto the matching saved host so it can wake it later. Empty if absent. pub mac: Vec, + /// The host's OS-identity chain from the mDNS `os` TXT (`windows` | `macos` | + /// `linux[/][/]`), sanitized ([`crate::os::sanitize_os`]) — drives the host + /// card's OS icon and is persisted like `mac`. Empty if absent (older host). + pub os: String, } /// One discovery update for the UI's advert map. @@ -96,6 +100,7 @@ pub fn browse() -> async_channel::Receiver { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(), + os: crate::os::sanitize_os(&val("os")), }) } ServiceEvent::ServiceRemoved(_ty, fullname) => { diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 75cb2555..90b35b1a 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -41,6 +41,9 @@ pub mod deeplink; // state machine every front-end drives, and the session spawn + stdout contract. #[cfg(any(target_os = "linux", windows))] pub mod orchestrate; +// The host's OS-identity chain (mDNS `os=` TXT): sanitize + icon-walk order. Pure string +// logic, built everywhere (the Apple/Android ports mirror it rather than link it). +pub mod os; // Client settings profiles: the override catalog + the one connect-time resolver // (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records // the bindings live on. diff --git a/crates/pf-client-core/src/os.rs b/crates/pf-client-core/src/os.rs new file mode 100644 index 00000000..c18cc7cc --- /dev/null +++ b/crates/pf-client-core/src/os.rs @@ -0,0 +1,104 @@ +//! The client half of the host's OS-identity advertisement (the mDNS `os=` TXT record — see the +//! host crate's `osinfo.rs` for the producer): sanitize the untrusted chain once, and turn it +//! into the icon-lookup order every front-end walks. +//! +//! The chain is slash-separated, generic → specific (`windows`, `macos`, +//! `linux[/][/]`, e.g. `linux/fedora/bazzite`). A UI resolves an icon by walking +//! [`os_icon_tokens`] (most-specific-first, brand aliases applied) and taking the first token it +//! has art for — so a client with no Bazzite mark lands on `fedora`, then generic `linux`, and an +//! unknown chain simply falls through to the UI's fallback glyph. Kept UI-agnostic here so the +//! GTK, Windows and console shells (and the Swift/Kotlin ports, held to the same rules) resolve +//! identically. + +/// Reduce a raw `os` TXT value to the trusted grammar: lowercase slash-separated tokens of +/// `[a-z0-9._-]` (each capped at 32 chars, at most 5 of them). mDNS is unauthenticated input — +/// anything outside the grammar is dropped, and a value that sanitizes to nothing becomes `""` +/// (same rendering as an older host that doesn't advertise `os` at all). +pub fn sanitize_os(raw: &str) -> String { + let tokens: Vec = raw + .to_lowercase() + .split('/') + .map(|t| { + t.chars() + .filter(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') + }) + .take(32) + .collect::() + }) + .filter(|t| !t.is_empty()) + .take(5) + .collect(); + tokens.join("/") +} + +/// The icon-lookup order for a chain: sanitized tokens most-specific-first, with brand aliases +/// applied (`macos` → `apple` art, `steamos` → `steam` art). A UI takes the first token it has +/// art for; an empty result (empty/garbage chain) means "no OS icon", exactly like an older host +/// that doesn't advertise one. +pub fn os_icon_tokens(chain: &str) -> Vec { + sanitize_os(chain) + .split('/') + .rev() + .filter(|t| !t.is_empty()) + .map(|t| match t { + "macos" => "apple".to_string(), + "steamos" => "steam".to_string(), + t => t.to_string(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_passes_well_formed_chains() { + assert_eq!(sanitize_os("windows"), "windows"); + assert_eq!(sanitize_os("linux/fedora/bazzite"), "linux/fedora/bazzite"); + assert_eq!( + sanitize_os("linux/opensuse/opensuse-tumbleweed"), + "linux/opensuse/opensuse-tumbleweed" + ); + } + + #[test] + fn sanitize_folds_case_and_drops_junk() { + assert_eq!(sanitize_os("Linux/Fedora"), "linux/fedora"); + assert_eq!(sanitize_os("linux/fe do ra!/§"), "linux/fedora"); + assert_eq!(sanitize_os("///"), ""); + assert_eq!(sanitize_os(""), ""); + } + + #[test] + fn sanitize_caps_token_length_and_count() { + let long = "x".repeat(80); + assert_eq!(sanitize_os(&long), "x".repeat(32)); + assert_eq!(sanitize_os("a/b/c/d/e/f/g"), "a/b/c/d/e"); + } + + #[test] + fn walk_is_most_specific_first() { + assert_eq!( + os_icon_tokens("linux/fedora/bazzite"), + ["bazzite", "fedora", "linux"] + ); + assert_eq!(os_icon_tokens("windows"), ["windows"]); + } + + #[test] + fn walk_applies_brand_aliases() { + assert_eq!(os_icon_tokens("macos"), ["apple"]); + assert_eq!( + os_icon_tokens("linux/arch/steamos"), + ["steam", "arch", "linux"] + ); + } + + #[test] + fn walk_of_nothing_is_empty() { + assert!(os_icon_tokens("").is_empty()); + assert!(os_icon_tokens("!!!").is_empty()); + } +} diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 330b256a..a0867a5c 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -144,6 +144,11 @@ pub struct KnownHost { /// pre-existing stores load; empty until first learned. #[serde(default)] pub mac: Vec, + /// The host's OS-identity chain (`windows` | `macos` | `linux[/][/]`) learned + /// from its mDNS `os` TXT while online, so the card's OS icon survives the host going to + /// sleep. `default` (and elided when empty) so pre-existing stores load unchanged. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub os: String, /// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md /// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a /// host your clipboard is a trust decision about that host. Default off; the host must @@ -183,6 +188,7 @@ impl Default for KnownHost { paired: false, last_used: None, mac: Vec::new(), + os: String::new(), clipboard_sync: false, profile_id: None, pinned_profiles: Vec::new(), @@ -290,6 +296,10 @@ impl KnownHosts { if !entry.mac.is_empty() { h.mac = entry.mac; } + // Same rule for the learned OS chain: only an upsert that carries one moves it. + if !entry.os.is_empty() { + h.os = entry.os; + } // Everything below is state the user set ON this record, which a refresh (a // reconnect, a re-pair, a rediscovery) never carries and therefore must never // clear: the per-host clipboard decision — which survives today only because this @@ -389,6 +399,28 @@ pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) { let _ = known.save(); } +/// Learn/refresh a saved host's OS-identity chain from its live advert (mDNS `os` TXT), matched +/// like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when unchanged, so +/// the hosts page can call it on every discovery tick without churning the store. +pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) { + if os.is_empty() { + return; + } + let mut known = KnownHosts::load(); + let Some(h) = known + .hosts + .iter_mut() + .find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port)) + else { + return; + }; + if h.os == os { + return; + } + h.os = os.to_string(); + let _ = known.save(); +} + /// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by /// fingerprint). No-op — and no disk write — when unchanged. Called from the wake-and-wait flow when /// a woken host reappears on a different IP than the stored one, so this and future connects dial the @@ -1077,6 +1109,28 @@ mod tests { assert_eq!(h.last_used, None); assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]); assert!(parse_hex32(&h.fp_hex).is_some()); + // A store predating the `os` field loads with it empty, and serializes back without + // the key (an older client reading the same file sees exactly what it wrote). + assert_eq!(h.os, ""); + assert!(!serde_json::to_string(&k).unwrap().contains("\"os\"")); + } + + /// The learned OS chain round-trips, and an absent key stays absent — the same + /// back-compat contract as every late `KnownHost` field. + #[test] + fn known_hosts_os_chain_round_trips() { + let k = KnownHosts { + hosts: vec![KnownHost { + name: "HTPC".into(), + addr: "192.168.1.181".into(), + port: 9777, + os: "linux/fedora/bazzite".into(), + ..Default::default() + }], + }; + let text = serde_json::to_string(&k).unwrap(); + let back: KnownHosts = serde_json::from_str(&text).unwrap(); + assert_eq!(back.hosts[0].os, "linux/fedora/bazzite"); } /// A pre-profiles known-hosts file loads unchanged — no binding, no pins — and its @@ -1130,6 +1184,7 @@ mod tests { paired: true, last_used: Some(1000), mac: vec!["aa:bb:cc:dd:ee:ff".into()], + os: "linux/fedora/bazzite".into(), clipboard_sync: true, profile_id: Some("aaaaaaaaaaaa".into()), pinned_profiles: vec!["bbbbbbbbbbbb".into()], @@ -1151,6 +1206,8 @@ mod tests { assert!(h.paired); assert_eq!(h.last_used, Some(1000)); assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]); + // The learned OS chain rides the same rule as `mac`: a carrier-less upsert keeps it. + assert_eq!(h.os, "linux/fedora/bazzite"); assert!(h.clipboard_sync); assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa")); assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]); diff --git a/crates/pf-console-ui/src/model.rs b/crates/pf-console-ui/src/model.rs index 5fbc7691..3d810df2 100644 --- a/crates/pf-console-ui/src/model.rs +++ b/crates/pf-console-ui/src/model.rs @@ -31,6 +31,10 @@ pub struct HostRow { pub can_wake: bool, /// Last successful connect (UNIX seconds) — the most-recent accent. pub last_used: Option, + /// The host's OS-identity chain (live advert preferred, else the stored one), for a + /// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a + /// follow-up — the Skia glyph set doesn't exist yet. + pub os: String, } /// The pairing ceremony's observable state (one at a time — the ceremony is modal). @@ -179,6 +183,7 @@ mod tests { mgmt_port: 47990, can_wake: false, last_used: None, + os: String::new(), }; shared.set_hosts(vec![row.clone()]); let g1 = shared.hosts_gen(); diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index e614d238..bc97dc49 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -483,6 +483,7 @@ mod tests { mgmt_port: 47990, can_wake, last_used: None, + os: String::new(), } } diff --git a/crates/pf-console-ui/src/screens/pair.rs b/crates/pf-console-ui/src/screens/pair.rs index 13a9d68c..ef117170 100644 --- a/crates/pf-console-ui/src/screens/pair.rs +++ b/crates/pf-console-ui/src/screens/pair.rs @@ -429,6 +429,7 @@ mod tests { mgmt_port: 47990, can_wake: false, last_used: None, + os: String::new(), } } diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 67ff0743..65a157ba 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -31,6 +31,7 @@ fn hosts() -> Vec { mgmt_port: 47990, can_wake: false, last_used: None, + os: String::new(), }; vec![ HostRow {