From 944c03dd32def9f996c5fe214f34ca729e6c32f3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 14:34:58 +0200 Subject: [PATCH] feat(client): the desktop clients wear the host's OS mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client half of the host's new `os=` advert, shared once in pf-client-core: `sanitize_os` (mDNS is unauthenticated input — lowercase `[a-z0-9._-]` tokens, capped) and `os_icon_tokens`, the most-specific-first walk with the brand aliases (`macos`→apple, `steamos`→steam) every platform resolves through. `DiscoveredHost` carries the chain, `KnownHost` persists it (`serde(default)`, elided when empty — older stores load unchanged and older clients read back exactly what they wrote), `upsert` moves it only when carried, and `learn_os` mirrors `learn_mac` — no-op, no disk write when unchanged — so the mark survives the host going to sleep. GTK shells: the card's status row leads with a recolorable symbolic glyph. That needed real embedded assets — the shells had none — so `data/` gains the ten `pf-os-*-symbolic` SVGs compiled into a gresource (new build.rs, glib-build-tools) and registered on the icon theme at startup; the Adwaita theme then tints them like every other status glyph. WinUI shell: reactor renders raster-from-URI only, so the embedded mid-gray PNGs (legible on both themes) materialize once into %LOCALAPPDATA%\punktfunk\os-icons\ — the library's poster-art pattern — and the tile's status row leads with a 16px image. The couch UI plumbs `HostRow.os` (live advert preferred, else the store) for a Skia glyph that is a declared follow-up; `--list-hosts` / `hosts --json` emit the stored chain so the Decky plugin can read it. A host that advertises no `os` renders everywhere exactly as it did before the field existed. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 8 ++ clients/cli/src/main.rs | 1 + clients/linux/Cargo.toml | 5 + clients/linux/build.rs | 15 +++ .../scalable/actions/pf-os-apple-symbolic.svg | 2 + .../scalable/actions/pf-os-arch-symbolic.svg | 2 + .../actions/pf-os-debian-symbolic.svg | 2 + .../actions/pf-os-fedora-symbolic.svg | 2 + .../scalable/actions/pf-os-linux-symbolic.svg | 2 + .../scalable/actions/pf-os-nixos-symbolic.svg | 2 + .../actions/pf-os-opensuse-symbolic.svg | 2 + .../scalable/actions/pf-os-steam-symbolic.svg | 2 + .../actions/pf-os-ubuntu-symbolic.svg | 2 + .../actions/pf-os-windows-symbolic.svg | 2 + clients/linux/data/resources.gresource.xml | 18 +++ clients/linux/src/app.rs | 14 +++ clients/linux/src/cli.rs | 2 + clients/linux/src/main.rs | 2 +- clients/linux/src/ui_hosts.rs | 39 +++++++ clients/session/Cargo.toml | 5 + clients/session/build.rs | 12 ++ .../scalable/actions/pf-os-apple-symbolic.svg | 2 + .../scalable/actions/pf-os-arch-symbolic.svg | 2 + .../actions/pf-os-debian-symbolic.svg | 2 + .../actions/pf-os-fedora-symbolic.svg | 2 + .../scalable/actions/pf-os-linux-symbolic.svg | 2 + .../scalable/actions/pf-os-nixos-symbolic.svg | 2 + .../actions/pf-os-opensuse-symbolic.svg | 2 + .../scalable/actions/pf-os-steam-symbolic.svg | 2 + .../actions/pf-os-ubuntu-symbolic.svg | 2 + .../actions/pf-os-windows-symbolic.svg | 2 + clients/session/data/resources.gresource.xml | 18 +++ clients/session/src/app.rs | 14 +++ clients/session/src/cli.rs | 2 + clients/session/src/console.rs | 7 ++ clients/session/src/main.rs | 2 +- clients/session/src/ui_hosts.rs | 39 +++++++ clients/windows/assets/os/apple.png | Bin 0 -> 723 bytes clients/windows/assets/os/arch.png | Bin 0 -> 919 bytes clients/windows/assets/os/debian.png | Bin 0 -> 1350 bytes clients/windows/assets/os/fedora.png | Bin 0 -> 1186 bytes clients/windows/assets/os/linux.png | Bin 0 -> 1345 bytes clients/windows/assets/os/nixos.png | Bin 0 -> 1331 bytes clients/windows/assets/os/opensuse.png | Bin 0 -> 1101 bytes clients/windows/assets/os/steam.png | Bin 0 -> 1391 bytes clients/windows/assets/os/ubuntu.png | Bin 0 -> 1402 bytes clients/windows/assets/os/windows.png | Bin 0 -> 355 bytes clients/windows/src/app/hosts.rs | 31 +++++- clients/windows/src/app/mod.rs | 3 + clients/windows/src/app/os_icons.rs | 62 +++++++++++ clients/windows/src/discovery.rs | 5 + clients/windows/src/trust.rs | 2 +- crates/pf-client-core/src/discovery.rs | 5 + crates/pf-client-core/src/lib.rs | 3 + crates/pf-client-core/src/os.rs | 104 ++++++++++++++++++ crates/pf-client-core/src/trust.rs | 57 ++++++++++ crates/pf-console-ui/src/model.rs | 5 + crates/pf-console-ui/src/screens/home.rs | 1 + crates/pf-console-ui/src/screens/pair.rs | 1 + crates/pf-console-ui/src/shell/tests.rs | 1 + 60 files changed, 516 insertions(+), 7 deletions(-) create mode 100644 clients/linux/build.rs create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-apple-symbolic.svg create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-arch-symbolic.svg create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-debian-symbolic.svg create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-fedora-symbolic.svg create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-linux-symbolic.svg create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-nixos-symbolic.svg create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-opensuse-symbolic.svg create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-steam-symbolic.svg create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-ubuntu-symbolic.svg create mode 100644 clients/linux/data/icons/scalable/actions/pf-os-windows-symbolic.svg create mode 100644 clients/linux/data/resources.gresource.xml create mode 100644 clients/session/data/icons/scalable/actions/pf-os-apple-symbolic.svg create mode 100644 clients/session/data/icons/scalable/actions/pf-os-arch-symbolic.svg create mode 100644 clients/session/data/icons/scalable/actions/pf-os-debian-symbolic.svg create mode 100644 clients/session/data/icons/scalable/actions/pf-os-fedora-symbolic.svg create mode 100644 clients/session/data/icons/scalable/actions/pf-os-linux-symbolic.svg create mode 100644 clients/session/data/icons/scalable/actions/pf-os-nixos-symbolic.svg create mode 100644 clients/session/data/icons/scalable/actions/pf-os-opensuse-symbolic.svg create mode 100644 clients/session/data/icons/scalable/actions/pf-os-steam-symbolic.svg create mode 100644 clients/session/data/icons/scalable/actions/pf-os-ubuntu-symbolic.svg create mode 100644 clients/session/data/icons/scalable/actions/pf-os-windows-symbolic.svg create mode 100644 clients/session/data/resources.gresource.xml create mode 100644 clients/windows/assets/os/apple.png create mode 100644 clients/windows/assets/os/arch.png create mode 100644 clients/windows/assets/os/debian.png create mode 100644 clients/windows/assets/os/fedora.png create mode 100644 clients/windows/assets/os/linux.png create mode 100644 clients/windows/assets/os/nixos.png create mode 100644 clients/windows/assets/os/opensuse.png create mode 100644 clients/windows/assets/os/steam.png create mode 100644 clients/windows/assets/os/ubuntu.png create mode 100644 clients/windows/assets/os/windows.png create mode 100644 clients/windows/src/app/os_icons.rs create mode 100644 crates/pf-client-core/src/os.rs 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 0000000000000000000000000000000000000000..f0ab8f0711c929b4a25527e01b6ad274e43a37c2 GIT binary patch literal 723 zcmV;^0xbQBP)LXc1&N zYT-26khKhsv~-jxav9iDS5vuvxg5`G!0~w8NeoZK6#M`x46c-GRwGCZ zPsEsq0~n>>nxLt*^H??P1XHhB`^Dt zRaK(xU4vRF^USV*i%re3;zhw! z-t1Fg)}p{G#`Hy6I!a*vENW`$d@N9EQ39ZXT+h@4q17@xLlu21kU9S&x9$vp9<)VQ z81Xr&-3CBJ)QD68t}Ov74S_&lX0c?3g8l)JZcF&*bv6Eb@Ka58XQ)*eS|dDZ(o!ZO zxP1TdXobf*%KLBz@?&C80iG2V7002ovPDHLk FV1oEzKxF^` literal 0 HcmV?d00001 diff --git a/clients/windows/assets/os/arch.png b/clients/windows/assets/os/arch.png new file mode 100644 index 0000000000000000000000000000000000000000..f656b3b41ab631dd3040b353963f5b324e993d7f GIT binary patch literal 919 zcmV;I18Dq-P)S;85EEI_kB(z|EmW(<}Me*==nDaA#(=BIj>m z$0H=E*i|RfHh|pS8QKY!0~XeZ#Bi&t&=f$qTuvby<=WwU6*P|cIs~Gt)m3N; z;A)V_0NYojQW%c`oomD`uEGrgN~I6GFXHh5;s~ zr?(@Tp3REruFKDD7$CmbF$UbS4gioHnk>)t-bfY+FnQ`!579YvyUR4jLTY>F%O8`*2v_s(byr zCo6rxo+x*r#*4_mH484=>atAQi?Q3xGC2Gm)^~jcT(_E#>Fp>30F(8aSvZ88o`|-T z#Jpp%anJMC+$F99BvY3L4Ga5ZYQX0hnIO;}Nfzc(@AN-1cpg*%eq95Pz33}>u3D|G zI@|&%6e?X#r~LxlwxaeU#+(!A`#{sVmZ6G8AWma+f<002ovPDHLkV1j{CuKxf4 literal 0 HcmV?d00001 diff --git a/clients/windows/assets/os/debian.png b/clients/windows/assets/os/debian.png new file mode 100644 index 0000000000000000000000000000000000000000..6781b555463971c72ac9e0fb131b2017ecfe90c2 GIT binary patch literal 1350 zcmV-M1-bf(P);F#nnZ5TBZcuJ>^XQ^X;0>@d5ILpTV`80dj4e4rsXZeOZFpu9q7MnWK;ap{$(9SczPhq> z(+vT*>2%S^PTeOk*Jphc<-^X4t?#<7dpRd~EcJxK9$?zlsj{{v9EuDZpdpru2~=E} zuF_l`4z0>nKUk4Sy4Wey0}7AyhdgV{+`84v4(0_&r_;gqcJ8x|{?woURaIZ94VNAn zu6b6mSbAbFdIXr#Z})w5#j+V9qS+ebpO8o-3eR=g$)2Md`^@G0%TB@LH)9S^Ss6O5 zXkGtM!S+OB=~@1o0RZLY|NV*tU34l;vU$L|`pLs)5YDH1;grlA@Ycry+H^x%oE%6z&~&b^&y4nKwFT)eR<8}samW~3TW5ta5&uMe+bvzGs1NjcV}a2 z^xM8wmu^mOkc^mSTxUOri5uVo2uxm_C09D%qfMfs2-FVLkl>67L zS$QfSKyd8jJFoP0DDMAxxVHAqc7NS~B@ht-(C*-$xf&PTu4C}HEuXoniiosc9oqna zijB3jv^XUtC0;f+a0yfd=ow7uSLWR4HekX#$>g|PHPLwT0sq*d&jII6uYBz{9|8c{ zH8eI*{m1J8r^k8OACE-R#o4`Jq~raPjW@=V(*>y)WLrna9YbF10cKXT69_gWc23C! z(9p1Jj1k@jUAsB4`^q}o(Y*oB>n(u34GsEEQ>$jP!oI4CWp8cR@N#cn0KuFA5bHga z3*d60E%Gd#BD&eJnf;M%>8Swg%F51GRh6C0)#!pQ(;{NOiN;bNAo@AVd61a`^A(;{W#iaUk_c6XhCKPz zhxW;J-N2~g3Fj3~78l8*p-}FL0MXcv8DJM7ngQ-XB?!{4*e|`H_vyN_va|W-4*>v( z#<#zZ%0iSA-UvHu?TQuWuh%5fwBuoM?1aE&J=fme`a{0U%3uYCM3f}35aC|aYi0sD z?{itzg_r#Z{5V`wzlQwfYAQ>=0zC?V$7?s!^j`r0s8R-45f%*IyKf0VZMgJ^2q|E^ zDae%HasYsi3x#WdLyByO#`c~{VShg2Bhcrv}JD)je%4Ul)=udi6z zs=?mb2!C2fe{XC`y>Lqb0BTpQ`lBo8%~g@TDtr`eO3qQ$VSiIzPdC&k65sx;LE6ae}z8(ZS3r>*jm2;W&_jyoB9yt=ydubTn*2O@?B@SaRAvH$=807*qo IM6N<$f)VMLT>t<8 literal 0 HcmV?d00001 diff --git a/clients/windows/assets/os/fedora.png b/clients/windows/assets/os/fedora.png new file mode 100644 index 0000000000000000000000000000000000000000..9871c2c9d773031b7145fd8206d4a15c4fc09b2d GIT binary patch literal 1186 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 Ds}6&2 literal 0 HcmV?d00001 diff --git a/clients/windows/assets/os/nixos.png b/clients/windows/assets/os/nixos.png new file mode 100644 index 0000000000000000000000000000000000000000..3d81fbe341ec03e3e6601d7973842179fff06175 GIT binary patch literal 1331 zcmV-31JlKC>PtA@A$B@ z+u3b9G=0_cH2-t{=bZn1-#7Ce_^+FU1-B*Bb1kELQLed>;zycmmj0HFwREN)7bTU& zYGhecL-b34MB=SmTyYNrC8|8%R3H0xv>sNN084cfP>xU$F$0@|vBr3`2RI9q1Lc;b z9sBm}vjIH3jlg|Cxd^X^_6Q52d=z+I-qyME{wx#`wT)H-CKG1%>0kO@Zr_orP|?^> zo_;!R0c9nU0y*wOTaA?n#v2+|9szkbpm?+KNCAooYr7x|LPSrcFs^z?M1E5A?ra%s>VuSR$@E-%K(@dJM$FHaQl4EMBPNkfr+cE&=wRvOX)mcV$ER*r)z3skGup3Xt%+0|q0@(+ zQF&l(ee~!^+?I~+S%TdQ6lID_of(Mip?L5|f$15a?p;&2^2KaU=vUC*o}PlRA>c}l z)*PUzuJX93q_bGj1}Pp~pKT8OWg>2KFeg-iiRfnFW*_!ho;YEA0QW|3J@7k#2nT`c zoFHLKQ+0LT_Q}?co#hd%6~mfCkw6@2f5gWTh(Ps*a`zx2u?HWsSfU6hJrzVu$HdT<DpP2slc%pz)e0Ya3P}rw z{gPe0-NQb^)g{i-5hA%=N7pe!uWXt#4T!7nCQa`-hgZok?jG>vX(?@ z4x?E6j#Pyz!7pkHMOxlj^jj1CQa)_@b^>?8)2)Z z)*Xe#6f;AR=|eT?6%jX&V>p=|-5_umh={f%Q*kl$sBq-U#dGffIF94CBvUnF=<%8J z#Bc)9<-^}5|44~sWChK(nHR))ThJ7b9TM2DA~lZVn1Hy`fIEbF90=ZiV>6WxSJ%eA z3c7~P1DA`vC51x+9*F|(oq~L==m!Pq4+6JmRB{dFKwdP$x&r?7Z^O2Z-3t_RaD>oR zW68|5@#vWxH<4PX!pB)`VLi3FZpHYA(sVjqr09-->l@&_|2)C+a{mhe*3`!i06DS# zOi(m#0sWUv73eGY#cAR5Ab>9TB;5n&~PltmhWLMAcH7}_KP zK|(h*V)ur36DocXI=gH4PSNcSqW?j?$X84HgT4qd5`sx0@yuClC;EOlN_A{GGEh&wTEFXlUI zFeK>5IDvFj=6}qKLtA4Tj{_vzJ6FlvUEXupM+-e$H*FfYVZpa|b}aPXy$sX>m8CAM zNUuy^nfwEwWyNO(95LAMa9<1$o;VN$fxl+K)^ujE!FFKjbxK_;1HfSeC-mu-#tj)s z7QR|^D%Dw?m)Z>KxQ14tJ&KWP4^!UOED-nFKjx*5Zth zgzLw#&5#Pz@lM*f+Ian&VX(+_Br?i zN2Qr90o4Mt{&hhyU$7^H^4|P06Msp}H_W`((=M6rdIYqj7@ozbNyHjfG8*~BS4L}b0XKs%;(mdIwq>XJdz)WvI2^`UerZc6k%-rSnQYIzfsHl+<<3Jg zo%zB0p~j~8rbKJ1tH)#?0-=c&42}UkLes$(0kbD`>!g!kF&d#SH0rA4sluo-bl$L2 zj7ENGYI^DHWmA&vnY|dbKrU?A83m%xn)I1q$l!a>Jg~XI2)HK_u?;=RR97>ymI;14 zvx6t&7mleQ2*T0IJGKb6aq+L9u0*W9p_H~djEds;WIP@pLDY=3!`3EZ4a>8G{r7?1 z1){?50LY%{PXayT@_5TdULSvk20@T-iZ!$V9hVwNc`0&Zf^ecZzAcqXMa}dpfZ<+D zraKeSTV_A%&l3km4VI-+sb~-c-r&uPS;U_cU(E#~l)VQ0J(kl}v~+abT9Tl|{^B^A zSM-H^c-n5D57{cvTXL?kxw(E2$O0j=vMPX)!I|B_Pe320fglL3cx=XE>rQLzve5?& zgwDCOr3fmON=5T3{~5R&I0f!0<)`-&$=2d6KV0U!D{_%VhW`c_zNdO-WnEoep_C#w zf{VxFBf{1JIbbIEgM>zQc>I}yW1u1zjv4;*V*ERkDF@@u++iwU2ZbvrkG-i{{#O3fB&9N Te1f=(00000NkvXXu0mjf3d{+J literal 0 HcmV?d00001 diff --git a/clients/windows/assets/os/steam.png b/clients/windows/assets/os/steam.png new file mode 100644 index 0000000000000000000000000000000000000000..523baaba59439d106edaef1b9dbe07fea764fef9 GIT binary patch literal 1391 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 IM6N<$f`yi`lmGw# literal 0 HcmV?d00001 diff --git a/clients/windows/assets/os/windows.png b/clients/windows/assets/os/windows.png new file mode 100644 index 0000000000000000000000000000000000000000..3da00a82cae1e21b5f1f48045f41b9ffd22867dd GIT binary patch literal 355 zcmV-p0i6DcP)9GW2)*g|4_i}`d667XauRrQbY2PA z>2%`m!O=-1&I9q}kU$Ci4A@^ayAs&#-tEzP%b2oD8Ij!?7UIR6ArPx98*h7q3rSh% z6fA?XL3ZBTc=%n(L9XXn@Ugra3?NDx&GVd}^jZ$PP^dAx0XDz}SWXe?s;t@hdJL2B z>a9?EW(gQHdn#+T31jn}k3yfNXO@6w;i0@+*A&x{S-7tX-2P9~02|=u1EafN{#Bv` z?K*(}UK2N`2(P1FZ*Xiud1ho5uOpwIs}9~~=^FrGXiOHEM700_002ovPDHLkV1lH> Bna}_L literal 0 HcmV?d00001 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 {