feat(client): the desktop clients wear the host's OS mark

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 17:58:33 +02:00
co-authored by Claude Fable 5
parent 4c2a6dd091
commit 944c03dd32
60 changed files with 516 additions and 7 deletions
+14
View File
@@ -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);
+2
View File
@@ -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
+1 -1
View File
@@ -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;
+39
View File
@@ -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-<t>-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<gtk::Image> {
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 {