Files
punktfunk/crates/pf-client-core/src/os.rs
T
enricobuehlerandClaude Fable 5 944c03dd32 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>
2026-07-29 17:58:33 +02:00

105 lines
3.8 KiB
Rust

//! 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[/<family>][/<id>]`, 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<String> = raw
.to_lowercase()
.split('/')
.map(|t| {
t.chars()
.filter(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')
})
.take(32)
.collect::<String>()
})
.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<String> {
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());
}
}