From b0ea1e6b5116c6cd1cc0a30293ce24829faa3227 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 10:59:28 +0200 Subject: [PATCH] fix(client/linux): the override marker appears on touch, profiles get a colour, and 4:4:4 gets a switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things found by actually driving the client. **The marker didn't appear until you reopened the dialog.** It was rendered once, at build time, from the stored overlay — so changing a setting inside a profile looked like it did nothing. The design says touching a control creates the override and the marker appears immediately, and it has to: a user who changes a row and sees no acknowledgement has no reason to believe it took. Every profileable row now builds its dot and reset hidden, and the same handler that records the touch reveals them. Resetting a row touched in the same sitting also un-touches it, so the commit can't re-write what the reset just removed. **Profiles had no colour.** `accent` has been in the schema since P0 and nothing could set it, which left every chip the same grey — and telling profiles apart at a glance across a grid is the entire reason chips exist. Creating a profile now picks a colour in the same breath as its name (hunting for it afterwards is what leaves them all grey), an existing profile has a Colour row, and host-card chips are tinted with it. A palette of eight rather than a free picker: legibility across light and dark is the job, and the schema still accepts any `#RRGGBB` a hand-edit or a future picker writes. Anything that isn't `#RRGGBB` is refused rather than interpolated into CSS, and each distinct colour registers one display-wide rule (per-widget providers are gone since GTK 4.10). **4:4:4 had no switch anywhere but Apple.** `VIDEO_CAP_444` has been on the wire for a while with only `punktfunk-probe`'s env var to set it. It is now a setting — and a profileable one, which is the point: full chroma is what makes small text and thin UI lines crisp, so a "Work" profile wants it where "Game" usually doesn't. The host still gates it on its own policy, HEVC, and a GPU that can actually encode it; advertising only says "I can decode this and I want it". Co-Authored-By: Claude Opus 5 (1M context) --- clients/linux/src/app.rs | 13 + clients/linux/src/ui_hosts.rs | 124 +- clients/linux/src/ui_settings.rs | 389 ++++-- clients/session/src/app.rs | 1016 ++++++++++++++ clients/session/src/cli.rs | 697 ++++++++++ clients/session/src/main.rs | 667 +--------- clients/session/src/shortcuts.rs | 102 ++ clients/session/src/spawn.rs | 118 ++ clients/session/src/ui_hosts.rs | 1363 +++++++++++++++++++ clients/session/src/ui_library.rs | 365 +++++ clients/session/src/ui_settings.rs | 1769 +++++++++++++++++++++++++ clients/session/src/ui_trust.rs | 350 +++++ crates/pf-client-core/src/profiles.rs | 9 + crates/pf-client-core/src/trust.rs | 8 + 14 files changed, 6188 insertions(+), 802 deletions(-) create mode 100644 clients/session/src/app.rs create mode 100644 clients/session/src/cli.rs create mode 100644 clients/session/src/shortcuts.rs create mode 100644 clients/session/src/spawn.rs create mode 100644 clients/session/src/ui_hosts.rs create mode 100644 clients/session/src/ui_library.rs create mode 100644 clients/session/src/ui_settings.rs create mode 100644 clients/session/src/ui_trust.rs diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 9d9ffb1a..e1c3f5e1 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -40,6 +40,19 @@ const CSS: &str = " -- a quote in here would end it.) */ .pf-override-dot { min-width: 8px; min-height: 8px; border-radius: 999px; background: @accent_color; } +/* Profile colour swatches (the accent a profile's chips carry). One class per palette entry + because a per-widget CSS provider for eight buttons is a lot of machinery for a dot. */ +.pf-swatch { min-width: 26px; min-height: 26px; border-radius: 999px; padding: 0; } +.pf-swatch-none { background: alpha(currentColor, 0.15); } +.pf-swatch-red { background: #e01b24; } +.pf-swatch-orange { background: #ff7800; } +.pf-swatch-yellow { background: #f6d32d; } +.pf-swatch-green { background: #33d17a; } +.pf-swatch-blue { background: #3584e4; } +.pf-swatch-purple { background: #9141ac; } +.pf-swatch-pink { background: #d16d9e; } +.pf-swatch-slate { background: #77767b; } +.pf-swatch-on { outline: 2px solid @accent_color; outline-offset: 2px; } /* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves the card's own elevation shadow intact. */ diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 4a425f51..28b65170 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -60,6 +60,15 @@ pub struct HostCard { connecting: bool, } +/// One catalog entry as the cards need it: what to call the profile, and the colour its chips +/// carry (`StreamProfile.accent`) — the field the schema reserved for exactly this. +#[derive(Clone, Debug)] +pub struct Profile { + pub id: String, + pub name: String, + pub accent: Option, +} + #[derive(Debug)] enum CardKind { Saved { @@ -69,7 +78,7 @@ enum CardKind { library_enabled: bool, /// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per /// refresh rather than re-read per card. - profiles: Rc>, + profiles: Rc>, /// `Some((id, name))` when this card is a PINNED host+profile pair rather than the /// host's primary card (design §5.2a): a one-click shortcut for a profile the user /// reaches for often. It is presentation state on the host record — never a second @@ -247,25 +256,18 @@ impl relm4::factory::FactoryComponent for HostCard { } else { pill("Trusted", "pf-accent") }); - // The chip says what a plain click on THIS card will do: its own profile on a - // pinned card, the host's binding on the primary one. A binding whose profile - // was deleted shows nothing and resolves as the defaults, which is exactly - // what will happen on connect (design §6). - let chip = pinned.as_ref().map(|(_, name)| name.as_str()).or_else(|| { - k.profile_id - .as_ref() - .and_then(|id| profiles.iter().find(|(pid, _)| pid == id)) - .map(|(_, name)| name.as_str()) - }); - if let Some(name) = chip { - status.append(&pill( - name, - if pinned.is_some() { - "pf-accent" - } else { - "pf-neutral" - }, - )); + // The chip says what a plain click on THIS card will do: its own profile on + // a pinned card, the host's binding on the primary one. Both resolve through + // the catalog so the chip can carry the profile's colour; a binding whose + // profile was deleted shows nothing and resolves as the defaults, which is + // exactly what will happen on connect (design §6). + let chip = pinned + .as_ref() + .map(|(id, _)| id.as_str()) + .or(k.profile_id.as_deref()) + .and_then(|id| profiles.iter().find(|p| p.id == id)); + if let Some(p) = chip { + status.append(&profile_pill(p)); } } CardKind::Discovered(_) => { @@ -456,11 +458,9 @@ impl relm4::factory::FactoryComponent for HostCard { // and its own removal, and deliberately NOT pair/rename/forget — those // belong to the host, and offering them here would blur what the card is. let with = gio::Menu::new(); - for (label, target) in std::iter::once(("Default settings", "")).chain( - profiles - .iter() - .map(|(id, name)| (name.as_str(), id.as_str())), - ) { + for (label, target) in std::iter::once(("Default settings", "")) + .chain(profiles.iter().map(|p| (p.name.as_str(), p.id.as_str()))) + { let item = gio::MenuItem::new(Some(label), None); item.set_action_and_target_value( Some("card.connect-with"), @@ -485,11 +485,9 @@ impl relm4::factory::FactoryComponent for HostCard { let with = gio::Menu::new(); let bind = gio::Menu::new(); let pin = gio::Menu::new(); - for (label, id) in std::iter::once(("Default settings", "")).chain( - profiles - .iter() - .map(|(id, name)| (name.as_str(), id.as_str())), - ) { + for (label, id) in std::iter::once(("Default settings", "")) + .chain(profiles.iter().map(|p| (p.name.as_str(), p.id.as_str()))) + { // A checkmark would be the natural cue for the current binding, but // a GMenu radio needs shared state per card; the bound profile is // named on the card's chip instead, visible without opening a menu. @@ -584,6 +582,59 @@ const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(12); /// The key a saved host is tracked under in the probe-results map: its fingerprint (stable /// across IP changes) when it has one, else `addr:port` (a not-yet-paired manual entry). +/// A profile chip in that profile's colour. `accent` is the field the catalog schema reserved +/// 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. +fn profile_pill(p: &Profile) -> gtk::Widget { + let label = gtk::Label::new(Some(&p.name)); + label.add_css_class("pf-pill"); + let Some(hex) = p.accent.as_deref().filter(|h| is_hex_colour(h)) else { + label.add_css_class("pf-neutral"); + return label.upcast(); + }; + label.add_css_class(&tint_class(hex)); + label.upcast() +} + +/// The CSS class that tints a pill with `hex`, registering its rule on the display the first +/// time that colour is seen. +/// +/// Per-widget providers are gone since GTK 4.10, and the colour is user data rather than one of +/// a fixed set, so the rule is generated once per distinct colour and added display-wide. A +/// handful of profiles means a handful of tiny rules, and re-rendering the grid (which happens +/// on every state change) reuses them instead of allocating more. +fn tint_class(hex: &str) -> String { + thread_local! { + static REGISTERED: RefCell> = RefCell::new(HashMap::new()); + } + let class = format!("pf-tint-{}", &hex[1..]); + REGISTERED.with_borrow_mut(|seen| { + if seen.contains_key(&class) { + return; + } + if let Some(display) = gtk::gdk::Display::default() { + let provider = gtk::CssProvider::new(); + provider.load_from_string(&format!( + ".pf-pill.{class} {{ color: {hex}; background: alpha({hex}, 0.18); }}" + )); + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + seen.insert(class.clone(), ()); + } + }); + class +} + +/// `#RRGGBB` only — the value is interpolated into CSS, so anything else is refused rather +/// than injected. +fn is_hex_colour(s: &str) -> bool { + s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit()) +} + fn saved_key(h: &KnownHost) -> String { if h.fp_hex.is_empty() { format!("{}:{}", h.addr, h.port) @@ -992,11 +1043,15 @@ impl HostsPage { .map(|(fp, _)| fp); let library_enabled = self.settings.borrow().library_enabled; // One catalog read per refresh, shared by every card's menus and chip. - let profiles: Rc> = Rc::new( + let profiles: Rc> = Rc::new( pf_client_core::profiles::ProfilesFile::load() .profiles .into_iter() - .map(|p| (p.id, p.name)) + .map(|p| Profile { + id: p.id, + name: p.name, + accent: p.accent, + }) .collect(), ); @@ -1030,9 +1085,10 @@ impl HostsPage { // They share the host's live status because they read the same record, and a // pin whose profile is gone simply doesn't render (design §5.2a). for id in &k.pinned_profiles { - let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else { + let Some(p) = profiles.iter().find(|p| &p.id == id) else { continue; }; + let (id, name) = (p.id.clone(), p.name.clone()); saved.push_back(HostCard { // The spinner belongs to whichever card was clicked; a pin has its own // key so two cards for one host don't both spin. @@ -1043,7 +1099,7 @@ impl HostsPage { profiles: profiles.clone(), recent: false, library_enabled, - pinned: Some((id.clone(), name.clone())), + pinned: Some((id, name)), }, }); } diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index e8b7999c..6842eeb5 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -45,6 +45,12 @@ impl Touched { fn has(&self, key: &str) -> bool { self.0.borrow().contains(key) } + + /// Undo a touch — the user reset this row after changing it, and "back to inheriting" is + /// the later, explicit intent. + fn forget(&self, key: &str) { + self.0.borrow_mut().remove(key); + } } /// `(0, 0)` = the native size of the monitor the window is on, resolved at connect. @@ -61,6 +67,57 @@ const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; /// `1.0` = Native. Applied at connect and each match-window resize. const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; +/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a +/// full colour picker: the point is telling profiles apart at a glance on a host card, which a +/// small set of legible, contrast-checked colours does better than free choice — and the +/// schema's `#RRGGBB` still accepts anything a future picker or a hand-edit writes. +const SWATCHES: &[(&str, &str, &str)] = &[ + ("", "pf-swatch-none", "No colour"), + ("#e01b24", "pf-swatch-red", "Red"), + ("#ff7800", "pf-swatch-orange", "Orange"), + ("#f6d32d", "pf-swatch-yellow", "Yellow"), + ("#33d17a", "pf-swatch-green", "Green"), + ("#3584e4", "pf-swatch-blue", "Blue"), + ("#9141ac", "pf-swatch-purple", "Purple"), + ("#d16d9e", "pf-swatch-pink", "Pink"), + ("#77767b", "pf-swatch-slate", "Slate"), +]; + +/// A row of colour swatches, calling back with the chosen `#RRGGBB` (empty = none). Used both +/// when creating a profile and when changing one later. +fn swatch_row(current: Option<&str>, on_pick: impl Fn(String) + 'static) -> gtk::Box { + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + let on_pick = Rc::new(on_pick); + let buttons: Rc>> = Rc::default(); + for (hex, class, name) in SWATCHES { + let b = gtk::Button::builder() + .css_classes(["pf-swatch", class, "circular"]) + .tooltip_text(*name) + .valign(gtk::Align::Center) + .build(); + if current.unwrap_or("") == *hex { + b.add_css_class("pf-swatch-on"); + } + { + let (on_pick, buttons, hex) = (on_pick.clone(), buttons.clone(), hex.to_string()); + b.connect_clicked(move |me| { + // The selection ring is exclusive, so clear every sibling before setting ours. + for (_, other) in buttons.borrow().iter() { + other.remove_css_class("pf-swatch-on"); + } + me.add_css_class("pf-swatch-on"); + on_pick(hex.clone()); + }); + } + buttons.borrow_mut().push((hex.to_string(), b.clone())); + row.append(&b); + } + row +} + /// The scope switcher, plus (in profile scope) that profile's management actions. /// /// Switching scope does not swap the rows in place: it closes the dialog — which commits the @@ -109,19 +166,27 @@ fn scope_group( // Creation is the one branch that has to ask a question first; the switch // happens in its callback, so a cancelled prompt leaves the dialog put. let (dialog, next) = (dialog.clone(), next.clone()); - prompt_name(&parent, "New profile", "Create", "", move |name| { - let mut catalog = ProfilesFile::load(); - if catalog.name_taken(&name, None) { - return; // the prompt already refuses these; belt and braces - } - let profile = StreamProfile::new(name); - let id = profile.id.clone(); - catalog.profiles.push(profile); - if catalog.save().is_ok() { - *next.borrow_mut() = Some(Scope::Profile(id)); - dialog.close(); - } - }); + prompt_name( + &parent, + "New profile", + "Create", + "", + true, + move |name, accent| { + let mut catalog = ProfilesFile::load(); + if catalog.name_taken(&name, None) { + return; // the prompt already refuses these; belt and braces + } + let mut profile = StreamProfile::new(name); + profile.accent = accent; + let id = profile.id.clone(); + catalog.profiles.push(profile); + if catalog.save().is_ok() { + *next.borrow_mut() = Some(Scope::Profile(id)); + dialog.close(); + } + }, + ); return; } *next.borrow_mut() = Some(match i { @@ -140,6 +205,26 @@ fn scope_group( std::mem::forget(row); if let Some(active) = active { + // Colour first: it is what the profile's chips carry on host cards, so it belongs with + // the profile's identity rather than buried behind a menu. + let colour = adw::ActionRow::builder() + .title("Colour") + .subtitle("Tints this profile's chips on host cards") + .use_markup(false) + .build(); + colour.add_suffix(&swatch_row(active.accent.as_deref(), { + let id = active.id.clone(); + move |hex| { + let mut catalog = ProfilesFile::load(); + if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) { + p.accent = (!hex.is_empty()).then(|| hex.clone()); + if let Err(e) = catalog.save() { + tracing::warn!(error = %format!("{e:#}"), "saving the profile colour"); + } + } + } + })); + g.add(&colour); let actions = adw::ActionRow::builder() .title(&active.name) .subtitle("This profile") @@ -197,19 +282,26 @@ fn run_profile_action( match action { ProfileAction::Rename => { let keep = id.clone(); - prompt_name(parent, "Rename profile", "Rename", name, move |new_name| { - let mut catalog = ProfilesFile::load(); - if catalog.name_taken(&new_name, Some(&keep)) { - return; - } - if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == keep) { - p.name = new_name; - } - if catalog.save().is_ok() { - *next.borrow_mut() = Some(Scope::Profile(keep.clone())); - dialog.close(); - } - }); + prompt_name( + parent, + "Rename profile", + "Rename", + name, + false, + move |new_name, _| { + let mut catalog = ProfilesFile::load(); + if catalog.name_taken(&new_name, Some(&keep)) { + return; + } + if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == keep) { + p.name = new_name; + } + if catalog.save().is_ok() { + *next.borrow_mut() = Some(Scope::Profile(keep.clone())); + dialog.close(); + } + }, + ); } ProfileAction::Duplicate => { let mut catalog = ProfilesFile::load(); @@ -286,7 +378,8 @@ fn prompt_name( heading: &str, accept: &str, initial: &str, - on_ok: impl Fn(String) + 'static, + with_colour: bool, + on_ok: impl Fn(String, Option) + 'static, ) { let dialog = adw::AlertDialog::new(Some(heading), None); let entry = adw::EntryRow::builder().title("Name").build(); @@ -296,6 +389,20 @@ fn prompt_name( .css_classes(["boxed-list"]) .build(); list.append(&entry); + // Creating a profile picks its colour here, in the same breath as its name — going hunting + // for it afterwards is exactly the friction that leaves every profile grey. + let accent: Rc>> = Rc::default(); + if with_colour { + let row = adw::ActionRow::builder() + .title("Colour") + .use_markup(false) + .build(); + row.add_suffix(&swatch_row(None, { + let accent = accent.clone(); + move |hex| *accent.borrow_mut() = (!hex.is_empty()).then(|| hex.clone()) + })); + list.append(&row); + } dialog.set_extra_child(Some(&list)); dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]); dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested); @@ -324,7 +431,7 @@ fn prompt_name( dialog.connect_response(Some("ok"), move |_, _| { let name = e.text().trim().to_string(); if !name.is_empty() { - on_ok(name); + on_ok(name, accent.borrow().clone()); } }); dialog.present(Some(parent)); @@ -369,6 +476,9 @@ fn commit_profile( if touched.has("hdr_enabled") { o.hdr_enabled = Some(values.hdr_enabled); } + if touched.has("enable_444") { + o.enable_444 = Some(values.enable_444); + } if touched.has("compositor") { o.compositor = Some(values.compositor.clone()); } @@ -867,6 +977,14 @@ pub fn show_scoped( the display supports it, tone-mapped otherwise", ) .build(); + let chroma_row = adw::SwitchRow::builder() + .title("Full chroma (4:4:4)") + .subtitle( + "Ask for full-colour video instead of subsampled \u{2014} small text and thin lines \ + stay crisp, at more bandwidth. HEVC only, and only when the host and its GPU can \ + encode it; otherwise the stream stays 4:2:0.", + ) + .build(); let decoder_row = ChoiceRow::new( &dialog, inline, @@ -1176,6 +1294,7 @@ pub fn show_scoped( invert_row.set_active(s.invert_scroll); mic_row.set_active(s.mic_enabled); hdr_row.set_active(s.hdr_enabled); + chroma_row.set_active(s.enable_444); library_row.set_active(s.library_enabled); surround_row.set_selected(match s.audio_channels { 6 => 1, @@ -1187,130 +1306,132 @@ pub fn show_scoped( set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32)); } - // ---- Override tracking (profile scope) ---- - // Installed after the seed block on purpose: `set_selected`/`set_active` during setup must - // not look like the user touching the control, or opening a profile would override every - // row in it. - if profile_mode { - macro_rules! on_change { - ($row:expr, $key:literal) => {{ - let t = touched.clone(); - $row.connect_changed(move |_| t.mark($key)); - }}; - } - macro_rules! on_toggle { - ($row:expr, $key:literal) => {{ - let t = touched.clone(); - $row.connect_active_notify(move |_| t.mark($key)); - }}; - } - on_change!(res_row, "resolution"); - on_change!(hz_row, "refresh_hz"); - on_change!(scale_row, "render_scale"); - on_change!(codec_row, "codec"); - on_change!(compositor_row, "compositor"); - on_change!(stats_row, "stats_verbosity"); - on_change!(touch_row, "touch_mode"); - on_change!(mouse_row, "mouse_mode"); - on_change!(surround_row, "audio_channels"); - on_change!(pad_row, "gamepad"); - on_toggle!(hdr_row, "hdr_enabled"); - on_toggle!(fullscreen_row, "fullscreen_on_stream"); - on_toggle!(inhibit_row, "inhibit_shortcuts"); - on_toggle!(invert_row, "invert_scroll"); - on_toggle!(mic_row, "mic_enabled"); - let t = touched.clone(); - bitrate_row.connect_value_notify(move |_| t.mark("bitrate_kbps")); - } - - // ---- Override markers + per-row reset (profile scope) ---- - // Every overridden row says so — an accent dot — and carries the only way back to - // inheriting: an explicit reset. Without this a profile is a one-way door, since the - // override model deliberately never infers "not overridden" from a value comparison. + // ---- Override markers, per-row reset, and the touch that creates an override ---- + // One pass per row, because the three are the same fact seen from different sides: the + // marker says "this profile changes this", the reset is the only way back (the override + // model never infers "not overridden" from a value comparison), and touching the control + // is what creates it. The marker therefore has to appear ON TOUCH, not on the next open — + // a user who changes a row and sees nothing has no reason to believe it took. + // + // Wired after the seed block on purpose: `set_selected`/`set_active` during setup must not + // look like the user touching anything, or opening a profile would override every row. if let Some(active) = &active { let o = &active.overrides; - let mark = |row: &adw::PreferencesRow, key: &'static str, overridden: bool| { - if !overridden { - return; - } - let Some(row) = row.downcast_ref::() else { - return; - }; + // Build the marker for one row and hand back a "now it's overridden" switch its + // control's change handler can pull. + let mark = |row: &adw::PreferencesRow, + key: &'static str, + overridden: bool| + -> Option> { + let row = row.downcast_ref::()?; let dot = gtk::Box::builder() .css_classes(["pf-override-dot"]) .valign(gtk::Align::Center) + .visible(overridden) .build(); row.add_prefix(&dot); let reset = gtk::Button::builder() .icon_name("edit-undo-symbolic") .tooltip_text("Reset to Default settings") .valign(gtk::Align::Center) + .visible(overridden) .css_classes(["flat"]) .build(); - let (dialog, next, cleared, scope) = ( - dialog.clone(), - next_scope.clone(), - cleared.clone(), - scope.clone(), - ); - reset.connect_clicked(move |_| { - // Queue the clear and re-open in the same scope: the close handler commits - // whatever else was edited first, then drops this field, and the rebuilt rows - // show the inherited value. One code path builds rows — including this one. - cleared.borrow_mut().insert(key); - *next.borrow_mut() = Some(scope.clone()); - dialog.close(); - }); + { + let (dialog, next, cleared, scope) = ( + dialog.clone(), + next_scope.clone(), + cleared.clone(), + scope.clone(), + ); + let touched = touched.clone(); + reset.connect_clicked(move |_| { + // Queue the clear and re-open in the same scope: the close handler commits + // whatever else was edited first, then drops this field, and the rebuilt + // rows show the inherited value. Dropping it from `touched` too keeps a + // reset-after-touch from re-writing what the reset just removed. + touched.forget(key); + cleared.borrow_mut().insert(key); + *next.borrow_mut() = Some(scope.clone()); + dialog.close(); + }); + } row.add_suffix(&reset); + Some(Box::new(move || { + dot.set_visible(true); + reset.set_visible(true); + })) }; - mark( - res_row.widget(), + + // `(row widget, overlay field, is it overridden right now)` — then the control's own + // change signal marks it touched AND lights the marker. + macro_rules! choice { + ($row:expr, $key:literal, $overridden:expr) => {{ + let show = mark($row.widget(), $key, $overridden); + let t = touched.clone(); + $row.connect_changed(move |_| { + t.mark($key); + if let Some(show) = &show { + show(); + } + }); + }}; + } + macro_rules! toggle { + ($row:expr, $key:literal, $overridden:expr) => {{ + let show = mark($row.upcast_ref(), $key, $overridden); + let t = touched.clone(); + $row.connect_active_notify(move |_| { + t.mark($key); + if let Some(show) = &show { + show(); + } + }); + }}; + } + + choice!( + res_row, "resolution", - o.width.is_some() || o.height.is_some() || o.match_window.is_some(), + o.width.is_some() || o.height.is_some() || o.match_window.is_some() ); - mark(hz_row.widget(), "refresh_hz", o.refresh_hz.is_some()); - mark(scale_row.widget(), "render_scale", o.render_scale.is_some()); - mark( - bitrate_row.upcast_ref(), - "bitrate_kbps", - o.bitrate_kbps.is_some(), - ); - mark(codec_row.widget(), "codec", o.codec.is_some()); - mark(hdr_row.upcast_ref(), "hdr_enabled", o.hdr_enabled.is_some()); - mark( - compositor_row.widget(), - "compositor", - o.compositor.is_some(), - ); - mark( - surround_row.widget(), - "audio_channels", - o.audio_channels.is_some(), - ); - mark(mic_row.upcast_ref(), "mic_enabled", o.mic_enabled.is_some()); - mark(touch_row.widget(), "touch_mode", o.touch_mode.is_some()); - mark(mouse_row.widget(), "mouse_mode", o.mouse_mode.is_some()); - mark( - invert_row.upcast_ref(), - "invert_scroll", - o.invert_scroll.is_some(), - ); - mark( - inhibit_row.upcast_ref(), - "inhibit_shortcuts", - o.inhibit_shortcuts.is_some(), - ); - mark(pad_row.widget(), "gamepad", o.gamepad.is_some()); - mark( - stats_row.widget(), - "stats_verbosity", - o.stats_verbosity.is_some(), - ); - mark( - fullscreen_row.upcast_ref(), + choice!(hz_row, "refresh_hz", o.refresh_hz.is_some()); + choice!(scale_row, "render_scale", o.render_scale.is_some()); + choice!(codec_row, "codec", o.codec.is_some()); + choice!(compositor_row, "compositor", o.compositor.is_some()); + choice!(stats_row, "stats_verbosity", o.stats_verbosity.is_some()); + choice!(touch_row, "touch_mode", o.touch_mode.is_some()); + choice!(mouse_row, "mouse_mode", o.mouse_mode.is_some()); + choice!(surround_row, "audio_channels", o.audio_channels.is_some()); + choice!(pad_row, "gamepad", o.gamepad.is_some()); + toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some()); + toggle!(chroma_row, "enable_444", o.enable_444.is_some()); + toggle!( + fullscreen_row, "fullscreen_on_stream", - o.fullscreen_on_stream.is_some(), + o.fullscreen_on_stream.is_some() ); + toggle!( + inhibit_row, + "inhibit_shortcuts", + o.inhibit_shortcuts.is_some() + ); + toggle!(invert_row, "invert_scroll", o.invert_scroll.is_some()); + toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some()); + { + let show = mark( + bitrate_row.upcast_ref(), + "bitrate_kbps", + o.bitrate_kbps.is_some(), + ); + let t = touched.clone(); + bitrate_row.connect_value_notify(move |_| { + t.mark("bitrate_kbps"); + if let Some(show) = &show { + show(); + } + }); + } } // ---- Assemble the category pages (the Apple revamp's map) ---- @@ -1353,6 +1474,7 @@ pub fn show_scoped( quality_group.add(&bitrate_row); quality_group.add(codec_row.widget()); quality_group.add(&hdr_row); + quality_group.add(&chroma_row); // Decoder and GPU are facts about THIS device's hardware — never per profile (tier G). if !profile_mode { quality_group.add(decoder_row.widget()); @@ -1496,6 +1618,7 @@ pub fn show_scoped( s.invert_scroll = invert_row.is_active(); s.mic_enabled = mic_row.is_active(); s.hdr_enabled = hdr_row.is_active(); + s.enable_444 = chroma_row.is_active(); s.audio_channels = match surround_row.selected() { 1 => 6, 2 => 8, diff --git a/clients/session/src/app.rs b/clients/session/src/app.rs new file mode 100644 index 00000000..e1c3f5e1 --- /dev/null +++ b/clients/session/src/app.rs @@ -0,0 +1,1016 @@ +//! The application shell as a relm4 component tree (phase 5 of punktfunk-planning +//! `linux-client-rearchitecture.md`): [`AppModel`] owns the window, navigation, trust +//! gate, and the spawned session child's lifecycle; the hosts page is a child component +//! ([`crate::ui_hosts`]); dialogs (trust, settings, library) are plain GTK invoked from +//! `update`. Every stream runs in the `punktfunk-session` Vulkan binary — the shell +//! never touches video. + +use crate::spawn::{self, SpawnOpts}; +use crate::trust::{self, Settings}; +use crate::ui_hosts::{ConnectRequest, HostsMsg, HostsOutput, HostsPage}; +use adw::prelude::*; +use gtk::{gdk, gio, glib}; +use punktfunk_core::client::NativeClient; +use punktfunk_core::config::{CompositorPref, GamepadPref}; +use relm4::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; + +pub const APP_ID: &str = "io.unom.Punktfunk"; + +/// Custom styles on top of libadwaita for the host cards: status pills, presence pips, +/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita +/// named palette so dark mode just works. +const CSS: &str = " +.pf-host-card { padding: 16px; } +/* The FlowBoxChild draws the hover/selection highlight AROUND the card (it wraps it + with its own padding), so its corners must run concentric with the card's 12px — + radius = card radius + the child's padding ring. */ +.pf-host-grid > flowboxchild { border-radius: 15px; } +.pf-pill { font-size: 0.72em; font-weight: bold; padding: 2px 10px; border-radius: 999px; + color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); } +.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); } +.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); } +.pf-pill.pf-neutral { color: alpha(currentColor, 0.75); background: alpha(currentColor, 0.12); } +.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px; + background: alpha(currentColor, 0.35); } +.pf-pip.pf-online { background: @success_color; } +/* An overridden row in profile scope: an accent dot in the prefix, so which settings this + profile changes is legible at a glance without reading every value. (Plain string literal + -- a quote in here would end it.) */ +.pf-override-dot { min-width: 8px; min-height: 8px; border-radius: 999px; + background: @accent_color; } +/* Profile colour swatches (the accent a profile's chips carry). One class per palette entry + because a per-widget CSS provider for eight buttons is a lot of machinery for a dot. */ +.pf-swatch { min-width: 26px; min-height: 26px; border-radius: 999px; padding: 0; } +.pf-swatch-none { background: alpha(currentColor, 0.15); } +.pf-swatch-red { background: #e01b24; } +.pf-swatch-orange { background: #ff7800; } +.pf-swatch-yellow { background: #f6d32d; } +.pf-swatch-green { background: #33d17a; } +.pf-swatch-blue { background: #3584e4; } +.pf-swatch-purple { background: #9141ac; } +.pf-swatch-pink { background: #d16d9e; } +.pf-swatch-slate { background: #77767b; } +.pf-swatch-on { outline: 2px solid @accent_color; outline-offset: 2px; } +/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's + rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves + the card's own elevation shadow intact. */ +.pf-recent { outline: 2px solid @accent_color; outline-offset: -2px; } +.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); } +.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); } +.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); } +.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); } +"; + +/// Everything the shell shares below the component tree. +pub struct AppModel { + pub window: adw::ApplicationWindow, + pub nav: adw::NavigationView, + toasts: adw::ToastOverlay, + pub settings: Rc>, + pub identity: (String, String), + /// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams + /// run in the session binary, which has its own. + pub gamepad: crate::gamepad::GamepadService, + /// Device lists for the settings pickers (GPUs via `punktfunk-session + /// --list-adapters` — the shell deliberately links no Vulkan itself — and audio + /// endpoints via the PipeWire registry), probed once at startup on a worker thread. + /// Empty until the probe lands — empty lists simply hide their pickers. + pub probes: Rc>, + hosts: Controller, + /// One session child at a time — connects while one runs are ignored. + busy: bool, + /// Armed by [`AppMsg::WakeConnect`] (a dial to a host that isn't advertising but has a + /// known MAC): if THAT dial's child exits with a connect failure, `SessionExited` falls + /// back into the visible wake-and-wait instead of an error. Consumed on the next exit and + /// matched against the exiting request, so it can never redirect an unrelated failure. + wake_fallback: Option, + /// The request-access "waiting for approval" dialog, closed on the first child + /// event. A shared slot (not a message): dialogs are main-thread GTK objects and + /// `AppMsg` must stay `Send` for the session child's reader thread. + waiting: Rc>>, +} + +#[derive(Debug)] +pub enum AppMsg { + /// A `punktfunk://` URL arrived (scheme handler, a shortcut, or a second invocation + /// forwarded to this instance by GApplication) — design/client-deep-links.md §4.1. + DeepLink(String), + /// The trust gate in front of every connect (rules 1–3, see `update`). + Connect(ConnectRequest), + /// Connect to a saved host that isn't advertising but has a known MAC: fire a wake + /// packet and DIAL IMMEDIATELY (mDNS absence ≠ unreachable — routed/Tailscale hosts + /// never advertise here); only a failed dial falls into the visible wake-and-wait. + WakeConnect(ConnectRequest), + /// The SPAKE2 PIN ceremony dialog. + Pair(ConnectRequest), + SpeedTest(ConnectRequest), + /// The desktop library page (mgmt port from the live advert when known). + OpenLibrary(ConnectRequest, Option), + /// Spawn the session child now (trust already decided; `tofu` = persist the + /// fingerprint once the child proves it). + StartSession { + req: ConnectRequest, + fp_hex: String, + tofu: bool, + opts: SpawnOpts, + }, + /// The child presented its first frame. + SessionReady { + req: ConnectRequest, + fp_hex: String, + tofu: bool, + persist_paired: bool, + }, + /// The child exited (the session is over, or the connect failed). + SessionExited { + req: ConnectRequest, + code: i32, + error: Option<(String, bool)>, + ended: Option, + tofu: bool, + }, + /// Request-access Cancel: the child was killed; release busy quietly. + CancelPending, + /// The speed-test dialog resolved (either way) — release `busy`. + SpeedTestDone, + ShowPreferences, + /// Re-open Preferences editing a specific layer — the settings scope switcher's + /// destination (design/client-settings-profiles.md §5.1). + ShowPreferencesScoped(crate::ui_settings::Scope), + ShowShortcuts, + ShowAbout, + ShowAddHost, + Toast(String), +} + +pub struct AppInit { + pub gamepad: crate::gamepad::GamepadService, +} + +pub struct AppWidgets {} + +impl SimpleComponent for AppModel { + type Init = AppInit; + type Input = AppMsg; + type Output = (); + type Root = adw::ApplicationWindow; + type Widgets = AppWidgets; + + fn init_root() -> Self::Root { + adw::ApplicationWindow::builder() + .title("Punktfunk") + .default_width(1200) + .default_height(780) + .build() + } + + fn init( + init: Self::Init, + window: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let identity = match trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + tracing::error!("client identity: {e:#}"); + std::process::exit(1); + } + }; + load_css(); + // Screenshot scenes must capture settled frames: kill every GTK/libadwaita + // animation (a headless session may starve the frame clock and leave a + // transition frozen mid-flight in the capture). + if crate::cli::shot_scene().is_some() { + if let Some(s) = gtk::Settings::default() { + s.set_gtk_enable_animations(false); + } + } + + let settings = Rc::new(RefCell::new(Settings::load())); + // Device lists for the settings pickers: probe in the background, ready long + // before the dialog opens. A missing session binary or absent PipeWire just + // leaves the corresponding list empty (and its picker hidden). + let probes: Rc> = Rc::default(); + { + let (tx, rx) = async_channel::bounded::(1); + std::thread::spawn(move || { + let adapters: Vec = + std::process::Command::new(crate::spawn::session_binary()) + .arg("--list-adapters") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + let (speakers, mics) = pf_client_core::audio::devices().unwrap_or_default(); + let _ = tx.send_blocking(crate::ui_settings::DeviceProbes { + adapters, + speakers, + mics, + }); + }); + let probes = probes.clone(); + glib::spawn_future_local(async move { + if let Ok(found) = rx.recv().await { + *probes.borrow_mut() = found; + } + }); + } + // Re-apply the persisted forwarded-controller pin (stable key; the service + // matches it whenever such a pad connects). + { + let forward = settings.borrow().forward_pad.clone(); + if !forward.is_empty() { + init.gamepad.set_pinned(Some(forward)); + } + } + + let hosts = + HostsPage::builder() + .launch(settings.clone()) + .forward(sender.input_sender(), |out| match out { + HostsOutput::Connect(req) => AppMsg::Connect(req), + HostsOutput::WakeConnect(req) => AppMsg::WakeConnect(req), + HostsOutput::Pair(req) => AppMsg::Pair(req), + HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req), + HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt), + HostsOutput::Toast(msg) => AppMsg::Toast(msg), + }); + + let nav = adw::NavigationView::new(); + nav.add(hosts.widget()); + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&nav)); + window.set_content(Some(&toasts)); + // Gaming-mode fallback (a bare launch under gamescope): fullscreen the shell. + if crate::cli::fullscreen_mode() { + window.fullscreen(); + } + + let model = AppModel { + window: window.clone(), + nav, + toasts, + settings, + identity, + gamepad: init.gamepad, + probes, + hosts, + busy: false, + wake_fallback: None, + waiting: Rc::new(RefCell::new(None)), + }; + install_actions(&model.window, &sender); + + // CI screenshot mode: dispatch the scripted scene once the window is actually + // mapped (AdwDialogs need a live window; relm4 maps it after `init` returns, so + // this can't run inline like the pre-relm4 `activate` path did). + if let Some(scene) = crate::cli::shot_scene() { + let ctx = crate::cli::ShotCtx { + window: model.window.clone(), + nav: model.nav.clone(), + hosts: model.hosts.sender().clone(), + settings: model.settings.clone(), + gamepad: model.gamepad.clone(), + identity: model.identity.clone(), + sender: sender.clone(), + }; + let fired = std::cell::Cell::new(false); + model.window.connect_map(move |_| { + if fired.replace(true) { + return; // map can fire more than once; the scene runs on the first + } + crate::cli::run_shot(&ctx, &scene); + }); + } + window.present(); + + // The deep-link seam is live from here: anything GApplication delivered during a cold + // start has been parked, and everything from now on arrives as a message. + LINK_TX.with_borrow_mut(|tx| *tx = Some(sender.input_sender().clone())); + for url in PENDING_LINKS.with_borrow_mut(std::mem::take) { + sender.input(AppMsg::DeepLink(url)); + } + + ComponentParts { + model, + widgets: AppWidgets {}, + } + } + + fn update(&mut self, msg: AppMsg, sender: ComponentSender) { + match msg { + AppMsg::DeepLink(url) => self.open_deep_link(&url, &sender), + // The trust gate (the host is the policy authority — it advertises + // `pair=optional` only when it accepts unpaired clients): + // 1. PINNED RECONNECT — a stored fingerprint connects silently. + // 2. FINGERPRINT CHANGED — known address, different fp: the impostor + // signal; force the PIN ceremony. + // 3a. NEW + pair=optional — offer TOFU alongside PIN. + // 3b. NEW otherwise — delegated approval (request access) or PIN. + AppMsg::Connect(req) => { + if self.busy { + return; + } + let known = trust::KnownHosts::load(); + match &req.fp_hex { + Some(fp_hex) => { + if known.find_by_fp(fp_hex).is_some() { + let fp_hex = fp_hex.clone(); + sender.input(AppMsg::StartSession { + req, + fp_hex, + tofu: false, + opts: SpawnOpts::default(), + }); + } else if known.find_by_addr(&req.addr, req.port).is_some() { + self.toast("Host fingerprint changed — re-pair with a PIN to continue"); + crate::ui_trust::pin_dialog( + &self.window, + &sender, + self.identity.clone(), + req, + ); + } else if req.pair_optional { + crate::ui_trust::tofu_dialog(&self.window, &sender, req); + } else { + crate::ui_trust::approval_dialog( + &self.window, + &sender, + self.waiting.clone(), + req, + ); + } + } + None => { + // Manual entry: a known address connects on its stored pin; + // an unknown one must pair — never silent TOFU. + match known + .find_by_addr(&req.addr, req.port) + .map(|k| k.fp_hex.clone()) + { + Some(fp_hex) => sender.input(AppMsg::StartSession { + req, + fp_hex, + tofu: false, + opts: SpawnOpts::default(), + }), + None => crate::ui_trust::approval_dialog( + &self.window, + &sender, + self.waiting.clone(), + req, + ), + } + } + } + } + AppMsg::WakeConnect(req) => { + if !self.busy { + // DIAL FIRST — no mDNS advert does NOT mean unreachable: a host reached over + // a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and + // gating the dial on presence bricked exactly those reconnects. Fire the magic + // packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep + // box is already booting while the dial times out, arm the wake-wait fallback + // for THIS request, and connect immediately. + // + // Auto-wake OFF (the Settings toggle, for VPN hosts that look offline when + // they aren't): no packet and no wake-and-wait fallback — the dial either + // succeeds or fails with the normal error. The host-card menu's explicit + // "Wake host" is deliberately not gated. + if self.settings.borrow().auto_wake { + crate::wol::wake(&req.mac, req.addr.parse().ok()); + self.wake_fallback = Some(req.clone()); + } + sender.input(AppMsg::Connect(req)); + } + } + AppMsg::Pair(req) => { + if !self.busy { + crate::ui_trust::pin_dialog(&self.window, &sender, self.identity.clone(), req); + } + } + AppMsg::SpeedTest(req) => self.speed_test(req, &sender), + AppMsg::SpeedTestDone => self.busy = false, + AppMsg::OpenLibrary(req, mgmt_port) => { + crate::ui_library::open(self, &sender, req, mgmt_port); + } + AppMsg::StartSession { + req, + fp_hex, + tofu, + opts, + } => { + if std::mem::replace(&mut self.busy, true) { + return; + } + self.hosts.emit(HostsMsg::ClearError); + self.hosts + .emit(HostsMsg::SetConnecting(Some(req.card_key()))); + let fullscreen = self.settings.borrow().fullscreen_on_stream; + if let Err(e) = spawn::spawn_session( + sender.input_sender().clone(), + req, + fp_hex, + tofu, + fullscreen, + opts, + ) { + self.busy = false; + self.hosts.emit(HostsMsg::SetConnecting(None)); + self.hosts.emit(HostsMsg::ShowError(e)); + } + } + AppMsg::SessionReady { + req, + fp_hex, + tofu, + persist_paired, + } => { + self.close_waiting(); + self.hosts.emit(HostsMsg::SetConnecting(None)); + if persist_paired { + // Request-access: the operator approved this device — a trusted + // PAIRED host from now on, like after a PIN ceremony. + trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true); + self.toast("Approved — connected"); + } else if tofu { + // The advertised fingerprint proved itself on a real connect. + trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, false); + self.toast(&format!( + "Trusted on first use — fingerprint {}…", + &fp_hex[..16.min(fp_hex.len())] + )); + } + self.hosts.emit(HostsMsg::Refresh); + } + AppMsg::SessionExited { + req, + code, + error, + ended, + tofu, + } => { + self.close_waiting(); + self.busy = false; + self.hosts.emit(HostsMsg::SetConnecting(None)); + // The dial-first wake fallback (armed by `WakeConnect`, consumed on every exit): + // a failed dial to the non-advertising host it was armed for falls into the + // visible wake-and-wait instead of an error alert. Matched by fingerprint (else + // address) so a stale armed request can never redirect another host's failure. + let wake_fb = + self.wake_fallback + .take() + .filter(|fb| match (&fb.fp_hex, &req.fp_hex) { + (Some(a), Some(b)) => a == b, + _ => fb.addr == req.addr && fb.port == req.port, + }); + match (code, error, ended) { + (0, _, None) => {} // clean end — back on the hosts page, no noise + (0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)), + (_, Some((_, true)), _) if !tofu => { + // The stored pin no longer matches (rotated cert or impostor). The host + // ANSWERED — never the wake fallback. + self.toast("Host fingerprint changed — re-pair with a PIN to continue"); + crate::ui_trust::pin_dialog( + &self.window, + &sender, + self.identity.clone(), + req, + ); + } + // A fingerprint mismatch means the host ANSWERED — reachable, so the plain + // error arms below handle it; only a genuine connect failure wakes. + (_, Some((_, false)), _) if wake_fb.is_some() => { + crate::ui_trust::wake_and_connect(&self.window, &sender, req) + } + (_, Some((msg, _)), _) => self + .hosts + .emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))), + (-1, None, _) => {} // killed (request-access cancel) — already handled + (_, None, _) if wake_fb.is_some() => { + crate::ui_trust::wake_and_connect(&self.window, &sender, req) + } + (code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!( + "Stream session failed (punktfunk-session exit {code})" + ))), + } + } + AppMsg::CancelPending => { + self.close_waiting(); + self.busy = false; + self.hosts.emit(HostsMsg::SetConnecting(None)); + self.toast("Cancelled — the request may still be pending on the host."); + } + AppMsg::ShowPreferences => sender.input(AppMsg::ShowPreferencesScoped( + crate::ui_settings::Scope::Defaults, + )), + AppMsg::ShowPreferencesScoped(scope) => { + let hosts = self.hosts.sender().clone(); + let reopen = sender.clone(); + crate::ui_settings::show_scoped( + &self.window, + self.settings.clone(), + &self.gamepad, + &self.probes.borrow(), + scope, + // The switcher closes the dialog to commit the layer it was editing, then + // asks for it back in the new scope — so the app owns the re-open and the + // dialog stays a pure view. + move |next| reopen.input(AppMsg::ShowPreferencesScoped(next)), + move || { + // The library toggle changes the saved cards' menu, and a profile edit + // changes the chips — re-render either way. + let _ = hosts.send(HostsMsg::Refresh); + }, + ); + } + AppMsg::ShowShortcuts => shortcuts_window(&self.window).present(), + AppMsg::ShowAbout => crate::ui_settings::show_about(&self.window), + AppMsg::ShowAddHost => self.hosts.emit(HostsMsg::ShowAddHost), + AppMsg::Toast(msg) => self.toast(&msg), + } + } +} + +impl AppModel { + pub fn toast(&self, msg: &str) { + self.toasts.add_toast(adw::Toast::new(msg)); + } + + /// Route a `punktfunk://` URL (design/client-deep-links.md §4.1). Parsing, host/profile + /// resolution and every refusal rule live in the shared brain (`plan_from_link`); this is + /// only the GTK end of it — turn the outcome into the same messages a card click raises, + /// so a link gets the identical wake, trust and error surfaces and NOT a second connect + /// path of its own. + fn open_deep_link(&mut self, url: &str, sender: &ComponentSender) { + use pf_client_core::deeplink; + use pf_client_core::orchestrate::{plan_from_link, PlanOutcome}; + use pf_client_core::profiles::ProfilesFile; + + tracing::debug!(%url, "deep link"); + let link = match deeplink::parse(url) { + Ok(l) => l, + Err(e) => return self.toast(&e.message()), + }; + let known = trust::KnownHosts::load(); + let outcome = plan_from_link( + &link, + &known, + &ProfilesFile::load(), + &self.settings.borrow(), + ); + match outcome { + Ok(PlanOutcome::Connect(plan)) => { + // Rule 2 of §3: never preempt a live session. Only this layer knows one is + // running, which is why the brain leaves the check here. + if self.busy { + return self.toast("A session is already running — end it first."); + } + let req = ConnectRequest { + name: plan.host.name.clone(), + addr: plan.host.addr.clone(), + port: plan.host.port, + fp_hex: plan.host.fp_hex.clone(), + pair_optional: false, + launch: plan.launch.clone().map(|id| (id.clone(), id)), + mac: plan.host.mac.clone(), + // `profile=` in a URL is a one-off, exactly like "Connect with ▸": it + // shapes this session and leaves the host's binding alone. + profile: plan.profile_override.clone(), + }; + // A link is a launch like any other: with a MAC it takes the dial-first wake + // path, so a sleeping host wakes instead of erroring. + sender.input(if plan.wake { + AppMsg::WakeConnect(req) + } else { + AppMsg::Connect(req) + }); + } + Ok(PlanOutcome::ConfirmUnknown(unknown)) => { + // Known-but-unpinned, or not known at all: the link may not pair and may not + // trust on its own, so it opens the ordinary ceremony under the user's eyes — + // the PIN dialog, seeded with what the link claimed. + if self.busy { + return self.toast("A session is already running — end it first."); + } + let req = ConnectRequest { + name: unknown.name.clone().unwrap_or_else(|| unknown.addr.clone()), + addr: unknown.addr.clone(), + port: unknown.port, + fp_hex: unknown.fp.clone(), + pair_optional: false, + launch: unknown.launch.clone().map(|id| (id.clone(), id)), + mac: Vec::new(), + profile: None, + }; + self.toast(&format!( + "{} isn't paired with this device yet — pair it to continue.", + req.name + )); + crate::ui_trust::pin_dialog(&self.window, sender, self.identity.clone(), req); + } + Ok(PlanOutcome::Unsupported(route)) => self.toast(&format!( + "Punktfunk can't open “{}” links yet.", + route.as_str() + )), + Err(e) => self.toast(&e.message()), + } + } + + fn close_waiting(&mut self) { + if let Some(w) = self.waiting.borrow_mut().take() { + w.close(); + } + } + + /// Measure the path to a host over the real data plane: connect, burst probe filler + /// for 2 s, report goodput · loss · a recommended bitrate, and apply it in one tap. + fn speed_test(&mut self, req: ConnectRequest, sender: &ComponentSender) { + if std::mem::replace(&mut self.busy, true) { + return; + } + let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32); + let status = gtk::Label::new(Some("Connecting…")); + let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name)); + dialog.set_extra_child(Some(&status)); + // Where a measured bitrate belongs is "the layer this host actually resolves bitrate + // from" (design/client-settings-profiles.md §5.3) — the long-standing wrong answer was + // always the global, so measuring the slow retro box downstairs re-tuned the desktop + // too. The target depends only on the host, so it is known before the result lands and + // the button can say where it will write. + let target = SpeedTestTarget::resolve(&req); + match &target { + SpeedTestTarget::Global => { + dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]); + } + SpeedTestTarget::Profile(p) => { + dialog.add_responses(&[ + ("close", "Close"), + ("apply", &format!("Apply to “{}”", p.name)), + ]); + } + // A bound host whose profile doesn't override bitrate could legitimately mean + // either: the user gets both, rather than us guessing which layer they meant. + SpeedTestTarget::Ask(p) => { + dialog.add_responses(&[ + ("close", "Close"), + ("apply-global", "Set as default"), + ("apply", &format!("Set in “{}”", p.name)), + ]); + dialog.set_response_enabled("apply-global", false); + } + } + dialog.set_response_enabled("apply", false); + dialog.set_close_response("close"); + dialog.present(Some(&self.window)); + + let (tx, rx) = + async_channel::bounded::>(1); + let identity = self.identity.clone(); + let (host, port) = (req.addr.clone(), req.port); + std::thread::spawn(move || { + let result = (|| { + let c = NativeClient::connect( + &host, + port, + punktfunk_core::config::Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }, + CompositorPref::Auto, + GamepadPref::Auto, + 0, // bitrate_kbps (host default) + 0, // video_caps: probe connect, nothing presents + 2, // audio_channels: stereo + crate::video::decodable_codecs(), // codecs (unused by the probe, but honest) + 0, // preferred_codec: no preference + None, // display_hdr: probe connect, nothing presents + 0, // client_caps: probe connect, nothing renders a cursor + None, // launch: probe connect, no game + pin, + Some(identity), + std::time::Duration::from_secs(15), + ) + .map_err(|e| format!("connect: {e:?}"))?; + c.request_probe(3_000_000, 2_000) + .map_err(|e| format!("probe: {e:?}"))?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + std::thread::sleep(std::time::Duration::from_millis(250)); + let r = c.probe_result(); + if r.done { + // Let the last UDP shards land before tearing down. + std::thread::sleep(std::time::Duration::from_millis(400)); + return Ok(c.probe_result()); + } + if std::time::Instant::now() > deadline { + return Err("probe timed out".to_string()); + } + } + })(); + let _ = tx.send_blocking(result); + }); + + let settings = self.settings.clone(); + let toasts = self.toasts.clone(); + let sender = sender.clone(); + glib::spawn_future_local(async move { + let outcome = rx.recv().await; + sender.input(AppMsg::SpeedTestDone); + match outcome { + Ok(Ok(r)) => { + let mbps = f64::from(r.throughput_kbps) / 1000.0; + let recommended_kbps = r.throughput_kbps / 10 * 7; + status.set_text(&format!( + "{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s", + r.loss_pct, + f64::from(recommended_kbps) / 1000.0, + )); + dialog.set_response_enabled("apply", true); + dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested); + if matches!(target, SpeedTestTarget::Ask(_)) { + dialog.set_response_enabled("apply-global", true); + } + let mbit = f64::from(recommended_kbps) / 1000.0; + { + let (settings, toasts) = (settings.clone(), toasts.clone()); + dialog.connect_response(Some("apply"), move |_, _| { + let where_to = match &target { + SpeedTestTarget::Global => { + let mut s = settings.borrow_mut(); + s.bitrate_kbps = recommended_kbps; + s.save(); + "the default bitrate".to_string() + } + SpeedTestTarget::Profile(p) | SpeedTestTarget::Ask(p) => { + write_profile_bitrate(&p.id, recommended_kbps); + format!("“{}”", p.name) + } + }; + toasts.add_toast(adw::Toast::new(&format!( + "{mbit:.0} Mbit/s set in {where_to}" + ))); + }); + } + dialog.connect_response(Some("apply-global"), move |_, _| { + let mut s = settings.borrow_mut(); + s.bitrate_kbps = recommended_kbps; + s.save(); + toasts.add_toast(adw::Toast::new(&format!( + "{mbit:.0} Mbit/s set in the default bitrate" + ))); + }); + } + Ok(Err(msg)) => status.set_text(&msg), + Err(_) => {} + } + }); + } +} + +/// Which layer a measured bitrate should land in for the host that was tested +/// (design/client-settings-profiles.md §5.3). +enum SpeedTestTarget { + /// No profile bound — the global default, i.e. what has always happened. + Global, + /// The bound profile already overrides bitrate, so that override is what this host reads. + Profile(pf_client_core::profiles::StreamProfile), + /// Bound, but the profile inherits bitrate: writing either layer is defensible, so ask. + Ask(pf_client_core::profiles::StreamProfile), +} + +impl SpeedTestTarget { + fn resolve(req: &crate::ui_hosts::ConnectRequest) -> SpeedTestTarget { + // Resolved exactly the way a connect resolves it: the one-off pick this test was + // started with (a pinned card carries one), else the host's binding. + let bound = trust::KnownHosts::load() + .hosts + .iter() + .find(|h| h.addr == req.addr && h.port == req.port) + .and_then(|h| h.profile_id.clone()); + let reference = match req.profile.as_deref() { + Some("") => return SpeedTestTarget::Global, + Some(id) => Some(id.to_string()), + None => bound, + }; + let Some(reference) = reference else { + return SpeedTestTarget::Global; + }; + let catalog = pf_client_core::profiles::ProfilesFile::load(); + match catalog.resolve(&reference).0 { + Some(p) if p.overrides.bitrate_kbps.is_some() => SpeedTestTarget::Profile(p.clone()), + Some(p) => SpeedTestTarget::Ask(p.clone()), + // A dangling binding resolves as no profile everywhere else; here too. + None => SpeedTestTarget::Global, + } + } +} + +/// Write a measured bitrate into one profile's overlay, leaving everything else alone. +fn write_profile_bitrate(id: &str, kbps: u32) { + let mut catalog = pf_client_core::profiles::ProfilesFile::load(); + let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) else { + return; // deleted while the test ran — the toast still tells the truth about the test + }; + p.overrides.bitrate_kbps = Some(kbps); + if let Err(e) = catalog.save() { + tracing::warn!(error = %format!("{e:#}"), "saving the measured bitrate"); + } +} + +thread_local! { + /// Where a delivered URL goes once the window exists. Both ends of this live on the GTK + /// main thread: `connect_open` fires there, and so does the model's `init`. + static LINK_TX: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; + /// URLs that arrived before the model existed — the cold-start case, where GApplication + /// runs `open` before `activate` builds the window. A dropped URL is the one outcome a + /// link must never have, so they wait here instead. + static PENDING_LINKS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Hand a URL to the running app, or park it until there is one. +fn deliver_deep_link(url: String) { + let queued = LINK_TX.with_borrow(|tx| match tx { + Some(tx) => { + let _ = tx.send(AppMsg::DeepLink(url.clone())); + false + } + None => true, + }); + if queued { + PENDING_LINKS.with_borrow_mut(|q| q.push(url)); + } +} + +pub fn run() -> glib::ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + // Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every + // physical pad Steam Input has virtualized; the Settings controller list needs the + // real devices (same rationale as the session binary). + for var in [ + "SDL_GAMECONTROLLER_IGNORE_DEVICES", + "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT", + ] { + if let Ok(v) = std::env::var(var) { + tracing::info!(var, value = %v, "clearing Steam's SDL device filter"); + std::env::remove_var(var); + } + } + // Headless paths (no GTK window). + if let Some(pin) = crate::cli::arg_value("--pair") { + return crate::cli::headless_pair(&pin); + } + if let Some(target) = crate::cli::arg_value("--library") { + return crate::cli::headless_library(&target); + } + if crate::cli::arg_value("--wake").is_some() { + return crate::cli::cli_wake(); + } + // Headless known-hosts management (list/add/edit/forget/reset) + reachability probes — + // the shared store the Decky plugin drives; returns None when argv names none of them. + if let Some(code) = crate::cli::headless_host_command() { + return code; + } + // Streams and the console library live in the session binary now — exec it, + // forwarding the relevant argv (the Decky wrapper keeps working through the shell + // until it's repointed). + // `--browse` may be bare now (the console home — hosts, pairing, settings), so the + // gate is the flag, not a value after it. + if crate::cli::arg_value("--connect").is_some() || crate::cli::arg_flag("--browse") { + return crate::cli::exec_session(); + } + + // HANDLES_OPEN is what makes `Exec=punktfunk-client %u` work: GApplication turns the URI + // into an `open` call, and — this is the part that matters — a SECOND invocation forwards + // its URI to the already-running instance over D-Bus and exits, so clicking a link with + // Punktfunk open reuses the window instead of racing a new one. + let mut builder = adw::Application::builder() + .application_id(APP_ID) + .flags(gio::ApplicationFlags::HANDLES_OPEN); + // Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps + // each launch its own primary instance. + if crate::cli::shot_scene().is_some() { + builder = + builder.flags(gio::ApplicationFlags::NON_UNIQUE | gio::ApplicationFlags::HANDLES_OPEN); + } + let adw_app = builder.build(); + adw_app.connect_open(|app, files, _hint| { + for f in files { + deliver_deep_link(f.uri().to_string()); + } + // `open` does not raise a window on its own; the model's activate handler does. + app.activate(); + }); + // One SDL context for the whole process, started while single-threaded. + let gamepad = crate::gamepad::GamepadService::start(); + // argv stays withheld from GApplication — except for a positional URL, which is exactly + // what GIO's single-instance forwarding is for. Passing it through means the FIRST + // instance's `open` fires locally and a later one's is delivered to the primary, with no + // IPC of our own. + let args: Vec = match crate::cli::deep_link_arg() { + Some(url) => vec![ + std::env::args() + .next() + .unwrap_or_else(|| "punktfunk-client".into()), + url, + ], + None => Vec::new(), + }; + let app = relm4::RelmApp::from_app(adw_app).with_args(args); + app.run::(AppInit { gamepad }); + glib::ExitCode::SUCCESS +} + +fn load_css() { + let provider = gtk::CssProvider::new(); + provider.load_from_string(CSS); + if let Some(display) = gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} + +/// Window actions behind the hosts page's header (the primary menu + "+") — thin +/// forwards into the message loop. +fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender) { + let add = |name: &str, msg: fn() -> AppMsg| { + let action = gio::SimpleAction::new(name, None); + let sender = sender.clone(); + action.connect_activate(move |_, _| sender.input(msg())); + action + }; + window.add_action(&add("preferences", || AppMsg::ShowPreferences)); + window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts)); + window.add_action(&add("about", || AppMsg::ShowAbout)); + window.add_action(&add("add-host", || AppMsg::ShowAddHost)); +} + +/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has +/// none); kept here as discoverable documentation. +pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow { + const UI: &str = r#" + + + 1 + + + + + Stream (session window) + + + Toggle fullscreen + F11 <Alt>Return + + + + + Release captured input (click the stream to capture) + <Control><Alt><Shift>q + + + + + Disconnect + <Control><Alt><Shift>d + + + + + Cycle the statistics overlay (off · compact · normal · detailed) + <Control><Alt><Shift>s + + + + + + + + +"#; + let builder = gtk::Builder::from_string(UI); + let window: gtk::ShortcutsWindow = builder + .object("shortcuts") + .expect("shortcuts window in builder XML"); + window.set_transient_for(Some(parent)); + window +} diff --git a/clients/session/src/cli.rs b/clients/session/src/cli.rs new file mode 100644 index 00000000..89889fa5 --- /dev/null +++ b/clients/session/src/cli.rs @@ -0,0 +1,697 @@ +//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the +//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot +//! scenes. + +use crate::app::AppModel; +use crate::trust::{forget_placeholder, KnownHost, KnownHosts}; +use crate::ui_hosts::{ConnectRequest, HostsMsg}; +use gtk::glib; +use gtk::prelude::*; +use punktfunk_core::client::NativeClient; +use relm4::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the +/// component parts, so the scene can be dispatched from the window's `map` callback. +pub struct ShotCtx { + pub window: adw::ApplicationWindow, + pub nav: adw::NavigationView, + pub hosts: relm4::Sender, + pub settings: Rc>, + pub gamepad: crate::gamepad::GamepadService, + pub identity: (String, String), + pub sender: ComponentSender, +} + +/// The value following `flag` in argv, if present (`--flag value`). +pub fn arg_value(flag: &str) -> Option { + std::env::args() + .skip_while(|a| a != flag) + .nth(1) + .filter(|v| !v.starts_with("--")) +} + +/// True if argv contains `flag` (a valueless switch). +pub fn arg_flag(flag: &str) -> bool { + std::env::args().any(|a| a == flag) +} + +/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — the deep-link +/// door (design/client-deep-links.md §4.1). It is positional, not a flag, because that is what +/// `Exec=punktfunk-client %u` hands us, what a `.desktop` shortcut embeds, and what a browser's +/// "Open Punktfunk?" prompt ends up invoking. Validation happens later, in the shared parser — +/// this only decides whether argv contains something addressed to us. +pub fn deep_link_arg() -> Option { + std::env::args().skip(1).find(|a| { + let lower = a.to_ascii_lowercase(); + lower.starts_with("punktfunk://") || lower.starts_with("pf://") + }) +} + +/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the +/// console library exec the session binary, which handles its own fullscreen). +pub fn fullscreen_mode() -> bool { + arg_flag("--fullscreen") + || std::env::var_os("SteamDeck").is_some() + || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() +} + +/// Split `host[:port]`: no colon defaults the port to 9777; a colon with an unparsable +/// port yields `None` for it (callers decide whether to default or bail). +fn parse_host_port(target: &str) -> (String, Option) { + match target.rsplit_once(':') { + Some((a, p)) => (a.to_string(), p.parse().ok()), + None => (target.to_string(), Some(9777)), + } +} + +/// `--connect` / `--browse`: streams and the console library live in the +/// `punktfunk-session` Vulkan binary — replace this process with it, forwarding the +/// relevant argv verbatim. This keeps the Decky wrapper (which launches the SHELL with +/// these flags) working unchanged until it's repointed at the session binary. +pub fn exec_session() -> glib::ExitCode { + use std::os::unix::process::CommandExt as _; + let forward = [ + "--connect", + "--browse", + "--fp", + "--launch", + "--mgmt", + "--connect-timeout", + ]; + let mut cmd = std::process::Command::new(crate::spawn::session_binary()); + let mut args = std::env::args().skip(1).peekable(); + while let Some(a) = args.next() { + if a == "--fullscreen" || a == "--stats" { + cmd.arg(a); + } else if forward.contains(&a.as_str()) { + cmd.arg(&a); + if let Some(v) = args.peek() { + if !v.starts_with("--") { + cmd.arg(args.next().unwrap()); + } + } + } + } + let err = cmd.exec(); // only returns on failure + eprintln!("exec punktfunk-session: {err}"); + glib::ExitCode::FAILURE +} + +/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the +/// known-hosts store as paired, so a later `--connect` connects silently. Same identity +/// store the streaming path uses, so pairing here makes the stream work. +/// Prints a one-line `paired : fp=` on success; exits non-zero on failure. +pub fn headless_pair(pin: &str) -> glib::ExitCode { + let Some(target) = arg_value("--connect") else { + eprintln!("--pair requires --connect host[:port]"); + return glib::ExitCode::FAILURE; + }; + let (addr, port) = parse_host_port(&target); + let port = port.unwrap_or(9777); + // The label the HOST stores this client under (its paired-devices list). + let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string()); + + let identity = match crate::trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + eprintln!("client identity: {e:#}"); + return glib::ExitCode::FAILURE; + } + }; + match crate::trust::pair_with_host(&addr, port, &identity, pin, &name) { + Ok(fp) => { + let fp_hex = crate::trust::hex(&fp); + crate::trust::persist_host( + &arg_value("--host-label").unwrap_or_else(|| addr.clone()), + &addr, + port, + &fp_hex, + true, + ); + // A host manually added via `--add-host` (no fingerprint yet) is stored as an + // addr-keyed placeholder; now that the ceremony yielded the real fingerprint, + // `persist_host` created the fp-keyed entry — drop the placeholder so the list + // shows this host once, not twice. + forget_placeholder(&addr, port); + println!("paired {addr}:{port} fp={fp_hex}"); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!( + "pairing failed: {} ({e:?})", + crate::trust::pair_error_message(&e) + ); + glib::ExitCode::FAILURE + } + } +} + +/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, +/// without opening a window. The MAC comes from the known-hosts store (learned from the +/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet. +pub fn cli_wake() -> glib::ExitCode { + let Some(target) = arg_value("--wake") else { + eprintln!("--wake requires host[:port]"); + return glib::ExitCode::FAILURE; + }; + let (addr, port) = parse_host_port(&target); + let port = port.unwrap_or(9777); + let mac = crate::trust::KnownHosts::load() + .hosts + .iter() + .find(|h| h.addr == addr && h.port == port) + .map(|h| h.mac.clone()) + .unwrap_or_default(); + if mac.is_empty() { + eprintln!( + "--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \ + advertised MAC is learned" + ); + return glib::ExitCode::FAILURE; + } + crate::wol::wake(&mac, addr.parse().ok()); + println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len()); + glib::ExitCode::SUCCESS +} + +/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real +/// mTLS + pinned-fingerprint client, no GTK window. The pin comes from `--fp HEX` when +/// given, else the known-hosts store (matched by address), else none (TOFU-accept). +pub fn headless_library(target: &str) -> glib::ExitCode { + let (addr, port) = match target.rsplit_once(':') { + Some((a, p)) if p.parse::().is_ok() => (a.to_string(), p.parse().unwrap()), + _ => (target.to_string(), crate::library::DEFAULT_MGMT_PORT), + }; + let identity = match crate::trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + eprintln!("client identity: {e:#}"); + return glib::ExitCode::FAILURE; + } + }; + let pin = arg_value("--fp") + .as_deref() + .and_then(crate::trust::parse_hex32) + .or_else(|| { + crate::trust::KnownHosts::load() + .hosts + .iter() + .find(|h| h.addr == addr) + .and_then(|h| crate::trust::parse_hex32(&h.fp_hex)) + }); + match crate::library::fetch_games(&addr, port, &identity, pin) { + Ok(games) => { + for g in &games { + println!("{}\t{}\t{}", g.id, g.store, g.title); + } + println!("{} game(s)", games.len()); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("library: {e}"); + glib::ExitCode::FAILURE + } + } +} + +// ----------------------------------------------------------------------------------------- +// Headless host-store management — the shared known-hosts store (`client-known-hosts.json`) +// is the SINGLE source of truth for every client on this device (the GTK shell, the Vulkan +// session, and the Decky plugin, which shells out to these modes). Exposing add/edit/forget/ +// list/reset here lets the Decky Gaming-Mode UI mutate exactly the store the desktop client +// reads, so a change in one surface shows up in the other. Reachability (`--list-hosts +// --probe`, `--reachable`) answers "is this host online?" WITHOUT mDNS, so a host reached +// over a routed network (Tailscale/VPN/another subnet) no longer reads as offline. +// ----------------------------------------------------------------------------------------- + +/// The per-probe budget: a cold host on a routed link answers in well under this, and every +/// saved host is probed in parallel so the wall-clock cost is one timeout, not the sum. +const PROBE_TIMEOUT: Duration = Duration::from_millis(2500); + +/// Selector for `--set-host`/`--forget-host`: a 64-hex fingerprint pins one entry across IP +/// changes; anything else is treated as `addr[:port]` (manual entries have no fingerprint). +enum Selector { + Fp(String), + Addr(String, u16), +} + +fn parse_selector(s: &str) -> Selector { + if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) { + Selector::Fp(s.to_lowercase()) + } else { + let (addr, port) = parse_host_port(s); + Selector::Addr(addr, port.unwrap_or(9777)) + } +} + +impl Selector { + fn matches(&self, h: &KnownHost) -> bool { + match self { + Selector::Fp(fp) => h.fp_hex.eq_ignore_ascii_case(fp), + Selector::Addr(addr, port) => h.addr == *addr && h.port == *port, + } + } +} + +/// Probe every saved host for reachability in parallel (shared with the hosts-page presence pips). +fn probe_all(hosts: &[KnownHost]) -> Vec { + crate::trust::probe_reachable_many( + hosts.iter().map(|h| (h.addr.clone(), h.port)).collect(), + PROBE_TIMEOUT, + ) +} + +/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin +/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe +/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its +/// own mDNS view). Shape: `{"hosts":[{name,addr,port,fp_hex,paired,mac,last_used,online}]}`. +pub fn headless_list_hosts() -> glib::ExitCode { + let known = KnownHosts::load(); + let online: Option> = arg_flag("--probe").then(|| probe_all(&known.hosts)); + let hosts: Vec = known + .hosts + .iter() + .enumerate() + .map(|(i, h)| { + serde_json::json!({ + "name": h.name, + "addr": h.addr, + "port": h.port, + "fp_hex": h.fp_hex, + "paired": h.paired, + "mac": h.mac, + "last_used": h.last_used, + "online": online.as_ref().map(|v| serde_json::Value::Bool(v[i])) + .unwrap_or(serde_json::Value::Null), + }) + }) + .collect(); + match serde_json::to_string(&serde_json::json!({ "hosts": hosts })) { + Ok(s) => { + println!("{s}"); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("list-hosts: {e}"); + glib::ExitCode::FAILURE + } + } +} + +/// `--reachable host[:port]` — probe one target and exit 0 (reachable) / 1 (not). A cheap +/// "is it online / test this address" check that never touches mDNS. +pub fn headless_reachable(target: &str) -> glib::ExitCode { + let (addr, port) = parse_host_port(target); + let port = port.unwrap_or(9777); + if NativeClient::probe(&addr, port, PROBE_TIMEOUT) { + println!("reachable {addr}:{port}"); + glib::ExitCode::SUCCESS + } else { + eprintln!("unreachable {addr}:{port}"); + glib::ExitCode::FAILURE + } +} + +/// `--add-host host[:port] [--host-label NAME] [--fp HEX]` — save a host by address so it can +/// be paired/streamed even when mDNS never sees it (a Tailscale/VPN box). Without `--fp` the +/// entry is an unpaired placeholder (keyed by address) the user pairs later — `headless_pair` +/// then replaces it with the fingerprinted entry. With `--fp` (e.g. carried from an advert) +/// it is pinned immediately as trusted-but-not-PIN-paired. Prints `added :`. +pub fn headless_add_host(target: &str) -> glib::ExitCode { + let (addr, port) = parse_host_port(target); + let port = port.unwrap_or(9777); + let name = arg_value("--host-label") + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| addr.clone()); + if let Some(fp_hex) = arg_value("--fp").filter(|f| crate::trust::parse_hex32(f).is_some()) { + // Fingerprint known up front: upsert the pinned entry (paired stays false — no PIN + // ceremony happened; a later `--pair` upgrades it). + crate::trust::persist_host(&name, &addr, port, &fp_hex.to_lowercase(), false); + forget_placeholder(&addr, port); + println!("added {addr}:{port} fp={}", fp_hex.to_lowercase()); + return glib::ExitCode::SUCCESS; + } + // No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists. + let mut known = KnownHosts::load(); + if let Some(h) = known + .hosts + .iter_mut() + .find(|h| h.addr == addr && h.port == port) + { + h.name = name; + } else { + known.hosts.push(KnownHost { + name, + addr: addr.clone(), + port, + ..Default::default() + }); + } + match known.save() { + Ok(()) => { + println!("added {addr}:{port}"); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("add-host: {e:#}"); + glib::ExitCode::FAILURE + } + } +} + +/// `--set-host [--host-label NAME] [--addr ADDR] [--port PORT]` — edit a saved +/// host: rename and/or re-point its address. Identified by fingerprint (survives IP changes) or +/// current address. Prints `updated `; fails if nothing matched. +pub fn headless_set_host(selector: &str) -> glib::ExitCode { + let sel = parse_selector(selector); + let mut known = KnownHosts::load(); + let Some(h) = known.hosts.iter_mut().find(|h| sel.matches(h)) else { + eprintln!("set-host: no saved host matches {selector:?}"); + return glib::ExitCode::FAILURE; + }; + if let Some(name) = arg_value("--host-label").map(|n| n.trim().to_string()) { + if !name.is_empty() { + h.name = name; + } + } + if let Some(addr) = arg_value("--addr").map(|a| a.trim().to_string()) { + if !addr.is_empty() { + h.addr = addr; + } + } + if let Some(port) = arg_value("--port").and_then(|p| p.trim().parse::().ok()) { + h.port = port; + } + let label = h.name.clone(); + match known.save() { + Ok(()) => { + println!("updated {label}"); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("set-host: {e:#}"); + glib::ExitCode::FAILURE + } + } +} + +/// `--forget-host ` — remove a saved host (drops the pinned fingerprint; a later +/// connect must re-pair/trust). Prints `forgot N`; succeeds even if nothing matched (idempotent). +pub fn headless_forget_host(selector: &str) -> glib::ExitCode { + let sel = parse_selector(selector); + let mut known = KnownHosts::load(); + let before = known.hosts.len(); + known.hosts.retain(|h| !sel.matches(h)); + let removed = before - known.hosts.len(); + if removed > 0 { + if let Err(e) = known.save() { + eprintln!("forget-host: {e:#}"); + return glib::ExitCode::FAILURE; + } + } + println!("forgot {removed}"); + glib::ExitCode::SUCCESS +} + +/// `--reset` — clear this device's client state: the saved known-hosts and the stream +/// settings. The persistent IDENTITY (`client-cert.pem`/`client-key.pem`) is deliberately +/// KEPT so the box isn't seen as a brand-new device everywhere (a re-pair still re-adds hosts); +/// a caller wanting a true factory reset removes those separately. Missing files are fine. +pub fn headless_reset() -> glib::ExitCode { + let Ok(dir) = crate::trust::config_dir() else { + eprintln!("reset: could not resolve config dir (HOME unset?)"); + return glib::ExitCode::FAILURE; + }; + let mut ok = true; + for name in ["client-known-hosts.json", "client-gtk-settings.json"] { + match std::fs::remove_file(dir.join(name)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + eprintln!("reset: {name}: {e}"); + ok = false; + } + } + } + if ok { + println!("reset"); + glib::ExitCode::SUCCESS + } else { + glib::ExitCode::FAILURE + } +} + +/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the +/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the +/// dispatch in `app.rs` is a single line. +pub fn headless_host_command() -> Option { + if arg_flag("--list-hosts") { + return Some(headless_list_hosts()); + } + if let Some(t) = arg_value("--reachable") { + return Some(headless_reachable(&t)); + } + if let Some(t) = arg_value("--add-host") { + return Some(headless_add_host(&t)); + } + if let Some(s) = arg_value("--set-host") { + return Some(headless_set_host(&s)); + } + if let Some(s) = arg_value("--forget-host") { + return Some(headless_forget_host(&s)); + } + if arg_flag("--reset") { + return Some(headless_reset()); + } + None +} + +/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots. +pub fn shot_scene() -> Option { + std::env::var("PUNKTFUNK_SHOT_SCENE") + .ok() + .filter(|s| !s.is_empty()) +} + +/// Render one mock-populated, host-free scene over the already-presented window, then +/// print `PF_SHOT_READY` once it has settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set +/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick +/// needed. The stream and gamepad-library scenes are gone with the pages (both live in +/// the session binary now). +pub fn run_shot(ctx: &ShotCtx, scene: &str) { + let sender = &ctx.sender; + // A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars). + let mock_req = || ConnectRequest { + name: "Living Room PC".to_string(), + addr: "192.168.1.42".to_string(), + port: 9777, + fp_hex: Some( + "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(), + ), + pair_optional: true, + launch: None, + mac: Vec::new(), + profile: None, + }; + let mock_advert = + |key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost { + key: key.to_string(), + fullname: format!("{key}._punktfunk._udp.local."), + name: name.to_string(), + addr: addr.to_string(), + port: 9777, + fp_hex: fp.to_string(), + pair: "required".to_string(), + mgmt_port: None, + mac: Vec::new(), + }; + + // What the self-capture renders: the main window, except for scenes that open their + // own toplevel (the shortcuts window). + let mut target: gtk::Widget = ctx.window.clone().upcast(); + let hosts = &ctx.hosts; + match scene { + // Saved hosts come from the seeded known-hosts store; on top, inject synthetic + // adverts through the same path the mDNS stream feeds. + "hosts" | "02-hosts" => { + let _ = hosts.send(HostsMsg::Advert(mock_advert( + "mock-online", + "Living Room PC", + "192.168.1.42", + "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00", + ))); + let _ = hosts.send(HostsMsg::Advert(mock_advert( + "mock-new", + "steamdeck", + "192.168.1.77", + "00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f", + ))); + } + "settings" | "03-settings" => { + // Mock devices so the shot shows the probe-dependent pickers populated. + let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice { + name: name.to_string(), + description: description.to_string(), + }; + let probes = crate::ui_settings::DeviceProbes { + adapters: vec![ + "NVIDIA GeForce RTX 4070".to_string(), + "AMD Radeon 780M".to_string(), + ], + speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")], + mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")], + }; + // `PUNKTFUNK_SHOT_SETTINGS_SCOPE=` captures the dialog in + // PROFILE scope — the second half of the settings surface (design + // client-settings-profiles.md §5.1), where only profileable rows render. + let scope = std::env::var("PUNKTFUNK_SHOT_SETTINGS_SCOPE") + .ok() + .filter(|v| !v.is_empty()) + .and_then(|reference| { + pf_client_core::profiles::ProfilesFile::load() + .resolve(&reference) + .0 + .map(|p| crate::ui_settings::Scope::Profile(p.id.clone())) + }) + .unwrap_or(crate::ui_settings::Scope::Defaults); + let dialog = crate::ui_settings::show_scoped( + &ctx.window, + ctx.settings.clone(), + &ctx.gamepad, + &probes, + scope, + |_| {}, + || {}, + ); + // Optional page for the capture (general/display/input/audio/controllers); + // the dialog opens on General otherwise. + if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") { + if !page.is_empty() { + use adw::prelude::PreferencesDialogExt as _; + dialog.set_visible_page_name(&page); + } + } + } + "trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()), + "pair" | "05-pair" => { + crate::ui_trust::pin_dialog(&ctx.window, sender, ctx.identity.clone(), mock_req()) + } + "addhost" | "06-addhost" => { + let _ = hosts.send(HostsMsg::ShowAddHost); + } + "shortcuts" | "07-shortcuts" => { + let w = crate::app::shortcuts_window(&ctx.window); + w.present(); + target = w.upcast(); + } + // The library page with injected entries: mixed stores exercising the badge set, + // no-art placeholders, and one solid-color texture standing in for a poster. + "library" | "08-library" => { + let (games, art) = mock_library(); + crate::ui_library::open_mock( + &ctx.nav, + ctx.identity.clone(), + sender, + mock_req(), + games, + art, + ); + } + other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"), + } + + let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(900); + let scene = scene.to_string(); + glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || { + use std::io::Write as _; + // Self-capture of the dialog scenes (trust/pair/settings/addhost) needs a GL + // renderer: `WidgetPaintable(window)` under the cairo software renderer doesn't + // composite the `AdwDialog` overlay layer (the dialog IS presented — the + // page-content scenes capture fine either way; CI uses GL or the X11 root-grab). + let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT") + .ok() + .filter(|p| !p.is_empty()); + if let Some(out) = &self_capture { + if let Err(e) = save_png(&target, out) { + eprintln!("PF_SHOT_ERROR scene={scene}: {e:#}"); + } + } + println!("PF_SHOT_READY scene={scene}"); + let _ = std::io::stdout().flush(); + // Self-capture mode: the shot is on disk — exit so back-to-back scene runs + // don't stack windows on a live desktop. + if self_capture.is_some() { + std::process::exit(0); + } + }); +} + +/// The mock game set for the `library` scene: mixed stores exercising the badge set, +/// plus one solid-colour poster texture. +fn mock_library() -> ( + Vec, + Vec<(String, gtk::gdk::Texture)>, +) { + let game = |id: &str, store: &str, title: &str| crate::library::GameEntry { + id: id.to_string(), + store: store.to_string(), + title: title.to_string(), + art: crate::library::Artwork::default(), + platform: None, + }; + let games = vec![ + game("steam:570", "steam", "Dota 2"), + game("steam:1091500", "steam", "Cyberpunk 2077"), + game("custom:emu-1", "custom", "RetroArch"), + game("heroic:fortnite", "heroic", "Fortnite"), + game("gog:witcher3", "gog", "The Witcher 3"), + game("lutris:osu", "lutris", "osu!"), + ]; + let art = vec![( + "steam:570".to_string(), + solid_texture(300, 450, 0x35, 0x84, 0xe4), + )]; + (games, art) +} + +/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster. +fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture { + let px = [r, g, b, 0xff].repeat((w * h) as usize); + gtk::gdk::MemoryTexture::new( + w, + h, + gtk::gdk::MemoryFormat::R8g8b8a8, + &glib::Bytes::from_owned(px), + (w * 4) as usize, + ) + .upcast() +} + +/// Snapshot `widget` (the whole window, dialogs included) into a PNG: WidgetPaintable → +/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`. +fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> { + use anyhow::Context as _; + let (w, h) = (widget.width(), widget.height()); + anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})"); + let paintable = gtk::WidgetPaintable::new(Some(widget)); + let snapshot = gtk::Snapshot::new(); + paintable.snapshot(&snapshot, f64::from(w), f64::from(h)); + let node = snapshot.to_node().context("empty snapshot")?; + let renderer = widget + .native() + .context("widget not realized")? + .renderer() + .context("no gsk renderer")?; + let texture = renderer.render_texture(node, None); + texture + .save_to_png(path) + .with_context(|| format!("save {path}"))?; + Ok(()) +} diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index a1706cd9..5c8d37b0 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -1,645 +1,42 @@ -//! `punktfunk-session` — the Vulkan session binary (punktfunk-planning -//! `linux-client-rearchitecture.md`, Phase 1: the software-path presenter MVP, which IS -//! the power-user CLI build). +//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita). //! -//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`, -//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity -//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client -//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side -//! makes the other connect silently. `--pair --connect host` runs the ceremony here, -//! with no window and no toolkit, for machines that have only a shell. -//! -//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after -//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` / -//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end, -//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed. +//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the +//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the +//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`). #![forbid(unsafe_code)] -#[cfg(all(any(target_os = "linux", windows), feature = "ui"))] -mod console; +// 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}; -#[cfg(any(target_os = "linux", windows))] -mod session_main { - use pf_client_core::gamepad::GamepadService; - use pf_client_core::session::SessionParams; - use pf_client_core::trust; - use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; - use std::sync::atomic::AtomicBool; - use std::sync::Arc; - use std::time::Duration; +#[cfg(target_os = "linux")] +mod app; +#[cfg(target_os = "linux")] +mod cli; +// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5). +#[cfg(target_os = "linux")] +mod shortcuts; +#[cfg(target_os = "linux")] +mod spawn; +#[cfg(target_os = "linux")] +mod ui_hosts; +#[cfg(target_os = "linux")] +mod ui_library; +#[cfg(target_os = "linux")] +mod ui_settings; +#[cfg(target_os = "linux")] +mod ui_trust; - pub const EXIT_CONNECT_FAILED: u8 = 2; - pub const EXIT_TRUST_REJECTED: u8 = 3; - pub const EXIT_PRESENTER_FAILED: u8 = 4; - - /// The value following `flag` in argv, if present (`--flag value`). - pub(crate) fn arg_value(flag: &str) -> Option { - std::env::args() - .skip_while(|a| a != flag) - .nth(1) - .filter(|v| !v.starts_with("--")) - } - - pub(crate) fn arg_flag(flag: &str) -> bool { - std::env::args().any(|a| a == flag) - } - - /// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a - /// manual launch under Gaming Mode does the right thing too. (Browse-mode only — - /// gated with `mod browse`, its one caller.) - #[cfg(feature = "ui")] - pub(crate) fn fullscreen_mode() -> bool { - arg_flag("--fullscreen") - || std::env::var_os("SteamDeck").is_some() - || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() - } - - /// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning - /// shell passes its own position so the session opens on the same monitor); absent or - /// unparsable = centered on the primary display. - pub(crate) fn window_pos() -> Option<(i32, i32)> { - let v = arg_value("--window-pos")?; - let (x, y) = v.split_once(',')?; - Some((x.trim().parse().ok()?, y.trim().parse().ok()?)) - } - - /// `--pair --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK - /// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk - /// client, a headless box, an image being provisioned. Writes the verified host into the - /// same known-hosts store `--connect` reads, so pairing here is exactly what makes the - /// later stream connect silently. - /// - /// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays - /// the desktop route) — the difference is only that this binary carries no toolkit, so it - /// is the one a minimal image installs. Present in the `--no-default-features` build too: - /// enrolment must not be the reason an embedded image has to pull in Skia. - fn headless_pair(pin: &str) -> u8 { - let Some(target) = arg_value("--connect") else { - eprintln!("--pair requires --connect host[:port]"); - return EXIT_CONNECT_FAILED; - }; - let (addr, port) = parse_host_port(&target); - // The label the HOST files this client under. A headless box has nobody to ask, so - // the hostname is the only name that will mean anything in the paired-devices list. - let name = arg_value("--name").unwrap_or_else(trust::device_name); - - let identity = match trust::load_or_create_identity() { - Ok(i) => i, - Err(e) => { - eprintln!("client identity: {e:#}"); - return EXIT_CONNECT_FAILED; - } - }; - match trust::pair_with_host(&addr, port, &identity, pin, &name) { - Ok(fp) => { - let fp_hex = trust::hex(&fp); - trust::persist_host( - &arg_value("--host-label").unwrap_or_else(|| addr.clone()), - &addr, - port, - &fp_hex, - true, - ); - trust::forget_placeholder(&addr, port); - println!("paired {addr}:{port} fp={fp_hex}"); - 0 - } - Err(e) => { - eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e)); - EXIT_TRUST_REJECTED - } - } - } - - /// `host[:port]`, port defaulting to the native 9777. - pub(crate) fn parse_host_port(target: &str) -> (String, u16) { - match target.rsplit_once(':') { - Some((a, p)) => match p.parse() { - Ok(port) => (a.to_string(), port), - Err(_) => { - eprintln!("unparsable port in '{target}', using default 9777"); - (a.to_string(), 9777) - } - }, - None => (target.to_string(), 9777), - } - } - - /// `--profile ` — the settings profile this one session runs with, overriding the - /// host's own binding for this launch only (never rebinding it): the shells' "Connect - /// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's - /// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is - /// how "Connect with ▸ Default settings" reaches a bound host. - fn profile_arg() -> Option { - arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default()) - } - - /// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the - /// shell's request-access flow passes ~185 s because the host PARKS the connection - /// until the operator clicks Approve. - pub(crate) fn connect_timeout() -> Duration { - Duration::from_secs( - arg_value("--connect-timeout") - .and_then(|v| v.parse().ok()) - .unwrap_or(15), - ) - } - - /// One session's pump parameters from the EFFECTIVE settings — shared by `--connect` - /// and every `--browse` launch. Explicit settings, `0` fields resolved to the - /// window's display (the GTK client reads the monitor under its window — same - /// contract). - /// - /// `settings` is what [`trust::effective_settings`] returned, never a raw - /// `Settings::load()`: both callers resolve the host's profile first, so the two - /// construction sites cannot drift (they historically did — touching one and not the - /// other is a Windows-only build break). `profile` is that profile's name, for the - /// stats overlay's first line. - #[allow(clippy::too_many_arguments)] - pub(crate) fn session_params( - settings: &trust::Settings, - profile: Option, - clipboard_override: Option, - addr: String, - port: u16, - pin: [u8; 32], - identity: (String, String), - launch: Option, - gamepad: &GamepadService, - native: Mode, - force_software: Arc, - vulkan: Option, - ) -> SessionParams { - // Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3). In spec - // mode the spawner already resolved it; otherwise this looks it up itself, which is - // the last store read the compat path still owes. `addr` is moved into the struct - // below, so read it first. - let clipboard = clipboard_override.unwrap_or_else(|| { - trust::KnownHosts::load() - .hosts - .iter() - .any(|h| h.addr == addr && h.port == port && h.clipboard_sync) - }); - // Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name` - // key) to OUR gamepad service — the shells' in-process services can't reach this - // process. Applied per params-build (idempotent; browse re-launches included) so - // it lands before the session attaches. Empty = automatic (most recent). - if !settings.forward_pad.is_empty() { - gamepad.set_pinned(Some(settings.forward_pad.clone())); - } - let mode = Mode { - width: if settings.width == 0 { - native.width - } else { - settings.width - }, - height: if settings.width == 0 { - native.height - } else { - settings.height - }, - refresh_hz: if settings.refresh_hz == 0 { - native.refresh_hz.max(30) - } else { - settings.refresh_hz - }, - }; - // Render scale: multiply the resolved mode (even + codec-clamped) so the host renders - // larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the - // Native/explicit resolution so it composes uniformly with both. - let (sw, sh) = punktfunk_core::render_scale::apply( - mode.width, - mode.height, - settings.render_scale, - punktfunk_core::render_scale::max_dimension(&settings.codec), - ); - let mode = Mode { - width: sw, - height: sh, - ..mode - }; - SessionParams { - host: addr, - port, - mode, - compositor: CompositorPref::from_name(&settings.compositor) - .unwrap_or(CompositorPref::Auto), - gamepad: { - // The setting AS CHOSEN goes to the pad service too, not just the Hello: the host - // builds each virtual pad from that pad's arrival and only falls back to this - // session default for a pad that never declares one, so an explicit choice that - // stopped here would be undone the moment a controller connected. - let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto); - gamepad.set_kind_override(chosen); - match chosen { - GamepadPref::Auto => gamepad.auto_pref(), - explicit => explicit, - } - }, - bitrate_kbps: settings.bitrate_kbps, - audio_channels: settings.audio_channels, - preferred_codec: settings.preferred_codec(), - // HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades. - video_caps: if settings.hdr_enabled { - punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR - } else { - 0 - }, - // No portable Wayland/X11 display-volume query yet, so the host keeps its EDID - // defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session - // pump) pins one manually. - display_hdr: None, - // The presenter renders the host cursor locally in desktop mouse mode (M2 cursor - // channel); capture-mode sessions keep the composited cursor, so only advertise - // when the session STARTS in desktop mode. The host gates further (Linux portal - // compositors only). - cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop, - mic_enabled: settings.mic_enabled, - clipboard, - // The Settings preference (auto → VAAPI where it exists; the presenter - // demotes to software on boxes whose Vulkan can't import the dmabufs). - // PUNKTFUNK_DECODER still overrides inside the decoder for bisects. - decoder: settings.decoder.clone(), - launch, - vulkan, - pin: Some(pin), - identity, - connect_timeout: connect_timeout(), - force_software, - profile, - } - } - - /// The window's starting size under Match-window: the persisted last size, so the - /// first connect's mode already matches the glass; `None` (policy off / never - /// stored) = the 1280×720 default. - pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> { - (settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0) - .then_some((settings.last_window_w, settings.last_window_h)) - } - - /// The Match-window policy hook for the presenter loop - /// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the - /// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's - /// logical window size (load-modify-save, like the console settings screen) so the - /// next launch opens at it. - /// The Match-window policy hook (design/midstream-resolution-resize.md D1/D2). The - /// callback used to load-modify-save the shared settings file from inside the renderer — - /// one of that file's five concurrent writers, for a value only the parent needs. It now - /// REPORTS the size on stdout and the spawner persists it - /// (design/client-architecture-split.md §5). - /// - /// `persist_locally` keeps a hand-run session remembering its own window: nobody is - /// listening to stdout there, so the event alone would drop the value. A spawned session - /// leaves the write to its parent, which is the whole point. - pub(crate) fn match_window( - settings: &trust::Settings, - persist_locally: bool, - ) -> Option> { - settings.match_window.then(|| { - Box::new(move |w: u32, h: u32| { - println!("{{\"window\":{{\"w\":{w},\"h\":{h}}}}}"); - if persist_locally { - pf_client_core::orchestrate::persist_window_size(w, h); - } - }) as Box - }) - } - - /// One JSON status line on stdout (the shell parses these; strings hand-escaped via - /// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its - /// failure through the same contract when spawned with `--json-status`. - pub(crate) fn json_line(key: &str, msg: &str, trust_rejected: Option) { - let escaped: String = msg - .chars() - .flat_map(|c| match c { - '"' => vec!['\\', '"'], - '\\' => vec!['\\', '\\'], - '\n' => vec!['\\', 'n'], - c if (c as u32) < 0x20 => vec![' '], - c => vec![c], - }) - .collect(); - match trust_rejected { - Some(t) => println!("{{\"{key}\":\"{escaped}\",\"trust_rejected\":{t}}}"), - None => println!("{{\"{key}\":\"{escaped}\"}}"), - } - } - - /// Steam Deck / RADV: Mesa gates Vulkan Video decode — the `VK_KHR_video_decode_*` - /// extensions AND the decode-capable queue family — behind `RADV_PERFTEST=video_decode`. - /// Without it the presenter's device advertises no decode queue, so `Decoder::new`'s - /// `auto` path can't build the Vulkan decoder and the session silently falls back to - /// VAAPI (whose separate-plane dmabuf import shows chroma fringing — green/yellow specks - /// around the cursor — on VanGogh). We want the Vulkan path, so opt in here, before the - /// RADV driver loads (the Vulkan instance is created later, inside `run_session`). - /// - /// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video - /// decode is already the default just no-ops. Append rather than clobber so a user's own - /// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice. - #[cfg(target_os = "linux")] - fn enable_radv_video_decode() { - const TOKEN: &str = "video_decode"; - match std::env::var("RADV_PERFTEST") { - Ok(v) if v.split(',').any(|t| t == TOKEN) => return, - Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")), - _ => std::env::set_var("RADV_PERFTEST", TOKEN), - } - tracing::info!( - radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(), - "opted into RADV Vulkan Video decode (Mesa gates it behind RADV_PERFTEST on the Deck)" - ); - } - - pub fn run() -> u8 { - // Logs to STDERR — stdout is the machine interface (ready/stats/error lines). - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info".into()), - ) - .init(); - - // `--list-adapters`: print the Vulkan physical devices' marketing names (one per - // line, discrete first) for the desktop shells' GPU picker, then exit. - if arg_flag("--list-adapters") { - return match pf_presenter::vk::list_adapters() { - Ok(names) => { - for n in names { - println!("{n}"); - } - 0 - } - Err(e) => { - eprintln!("list-adapters: {e:#}"); - EXIT_PRESENTER_FAILED - } - }; - } - - // `--list-audio`: the PipeWire endpoints the settings pickers offer, as - // `sink|sourcenode.namedescription` lines — a debug window into the - // same enumeration the GTK shell probes. - #[cfg(target_os = "linux")] - if arg_flag("--list-audio") { - return match pf_client_core::audio::devices() { - Ok((sinks, sources)) => { - for d in sinks { - println!("sink\t{}\t{}", d.name, d.description); - } - for d in sources { - println!("source\t{}\t{}", d.name, d.description); - } - 0 - } - Err(e) => { - eprintln!("list-audio: {e:#}"); - EXIT_PRESENTER_FAILED - } - }; - } - - // `--pair `: enrol this machine against a host and exit. DEPRECATED — pairing is - // a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell - // (design/client-architecture-split.md §5). It still works, with a notice, for the one - // release this needs; a renderer owning a trust ceremony is exactly the mixing of - // concerns the split exists to undo. - if let Some(pin) = arg_value("--pair") { - eprintln!( - "note: punktfunk-session --pair is deprecated \u{2014} use `punktfunk pair \ - ` instead (same store, same result)." - ); - return headless_pair(&pin); - } - - // Before any Vulkan call: make RADV expose its video-decode queue + extensions so the - // decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV). - // Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally. - #[cfg(target_os = "linux")] - enable_radv_video_decode(); - - // The Settings device picks → env, unless the user already forced one by hand: - // the GPU (the shells' pickers store the adapter's marketing name) for the - // presenter's device selection, and the audio endpoints (PipeWire node names) - // for the playback/mic streams' `target.object`. Before any Vulkan call, like - // the RADV knob (covers --connect and --browse). - { - let s = trust::Settings::load(); - for (var, value) in [ - ("PUNKTFUNK_VK_ADAPTER", &s.adapter), - ("PUNKTFUNK_AUDIO_SINK", &s.speaker_device), - ("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device), - ] { - if std::env::var_os(var).is_none() && !value.is_empty() { - std::env::set_var(var, value); - } - } - } - - // Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming - // every pad Steam Input has virtualized; capturing the Deck's real built-in - // controller needs it cleared (same rationale as the GTK client's `app::run`). - for var in [ - "SDL_GAMECONTROLLER_IGNORE_DEVICES", - "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT", - ] { - if let Ok(v) = std::env::var(var) { - tracing::info!(var, value = %v, "clearing Steam's SDL device filter"); - std::env::remove_var(var); - } - } - - if arg_flag("--browse") { - // Bare `--browse` opens the console home (hosts, pairing, settings); - // `--browse host[:port]` opens straight into that host's library. - let target = arg_value("--browse"); - #[cfg(feature = "ui")] - return crate::console::run(target.as_deref()); - #[cfg(not(feature = "ui"))] - { - let _ = target; - eprintln!( - "--browse needs the console UI — this is the minimal build \ - (rebuild without --no-default-features)" - ); - return EXIT_PRESENTER_FAILED; - } - } - let Some(target) = arg_value("--connect") else { - eprintln!( - "usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\ - \x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\ - \x20 punktfunk-session --pair --connect host[:port] [--name LABEL]\n\ - \n\ - Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\ - gamepad console instead: bare --browse is the host list (discovery, PIN\n\ - pairing, settings, wake-on-LAN); with a target it opens that host's game\n\ - library. --profile picks a settings profile by id or name for this session\n\ - only (\"\" = the global defaults); without it the host's own profile applies.\n\ - --connect never dials a host it has no pinned fingerprint for —\n\ - enrol with --pair (no display needed), in the console, or from the desktop\n\ - client." - ); - return EXIT_CONNECT_FAILED; - }; - let (addr, port) = parse_host_port(&target); - - let identity = match trust::load_or_create_identity() { - Ok(i) => i, - Err(e) => { - json_line("error", &format!("client identity: {e:#}"), None); - return EXIT_CONNECT_FAILED; - } - }; - // `--resolved-spec `: the spawner already did the resolving, so this process - // performs ZERO store reads (design/client-architecture-split.md §5) — no Settings - // load, no known-hosts lookup, no profile resolution. Without it (a hand-run - // `--connect`, an old Decky script) the session resolves for itself through the SAME - // helper, so the two modes cannot drift. - let spec = arg_value("--resolved-spec").map(std::path::PathBuf::from); - let (settings, profile_name, clipboard_override) = match &spec { - Some(path) => match pf_client_core::orchestrate::ResolvedSpec::read(path) { - Ok(s) => { - tracing::info!(path = %path.display(), "running from a resolved spec"); - (s.settings, s.profile, Some(s.clipboard)) - } - Err(e) => { - json_line("error", &format!("resolved spec: {e}"), None); - return EXIT_CONNECT_FAILED; - } - }, - None => { - let (settings, profile) = - trust::effective_settings(&addr, port, profile_arg().as_deref()); - (settings, profile.map(|p| p.name), None) - } - }; - if let Some(name) = &profile_name { - tracing::info!(profile = %name, "streaming with a settings profile"); - } - - // Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin - // connects silently; an unknown host is REFUSED — there is no dialog here, and a - // silent TOFU would defeat the pinning model. Pair via the desktop client. - let known = trust::KnownHosts::load(); - let known_host = known - .hosts - .iter() - .find(|h| h.addr == addr && h.port == port); - let pin = arg_value("--fp") - .as_deref() - .and_then(trust::parse_hex32) - .or_else(|| known_host.and_then(|h| trust::parse_hex32(&h.fp_hex))); - let Some(pin) = pin else { - json_line( - "error", - &format!( - "no pinned fingerprint for {addr}:{port} — pair first \ - (punktfunk-session --pair --connect {addr}:{port}) or pass --fp HEX" - ), - Some(true), - ); - return EXIT_TRUST_REJECTED; - }; - - let host_label = known_host.map_or_else(|| addr.clone(), |h| h.name.clone()); - let launch = arg_value("--launch"); - let title = launch - .clone() - .map_or_else(|| host_label.clone(), |id| format!("{host_label} · {id}")); - - let fullscreen = arg_flag("--fullscreen") - || std::env::var_os("SteamDeck").is_some() - || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some(); - - let opts = pf_presenter::SessionOpts { - window_title: format!("Punktfunk · {title}"), - fullscreen, - window_pos: window_pos(), - // `--stats` forces the overlay visible (tooling/debug runs) without - // demoting an explicitly chosen richer tier. - stats_verbosity: match settings.stats_verbosity() { - trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal, - v => v, - }, - touch_mode: settings.touch_mode(), - mouse_mode: settings.mouse_mode(), - invert_scroll: settings.invert_scroll, - json_status: true, - on_connected: Some(Box::new(|fingerprint: [u8; 32]| { - // This host's card carries the accent bar in the desktop client now. - trust::touch_last_used(&trust::hex(&fingerprint)); - })), - // The Skia console UI (stats OSD, capture HUD) — compiled out of the - // power-user build (`--no-default-features` drops the `ui` feature). - #[cfg(feature = "ui")] - overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())), - #[cfg(not(feature = "ui"))] - overlay: None, - window_size: window_size(&settings), - // A spawned session (spec mode) reports its window; a hand-run one persists it. - match_window: match_window(&settings, spec.is_none()), - render_scale: settings.render_scale, - render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec), - }; - - let outcome = - pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| { - session_params( - &settings, - profile_name, - clipboard_override, - addr, - port, - pin, - identity, - launch, - gamepad, - native, - force_software, - vulkan, - ) - }); - - match outcome { - Ok(pf_presenter::Outcome::Ended(None)) => 0, - Ok(pf_presenter::Outcome::Ended(Some(reason))) => { - // The host ending the session (game quit, host shutdown) is a normal end - // for a one-shot stream binary — report the reason, exit clean. - json_line("ended", &reason, None); - 0 - } - Ok(pf_presenter::Outcome::ConnectFailed { - msg, - trust_rejected, - }) => { - json_line("error", &msg, Some(trust_rejected)); - if trust_rejected { - EXIT_TRUST_REJECTED - } else { - EXIT_CONNECT_FAILED - } - } - Err(e) => { - json_line("error", &format!("presenter: {e:#}"), None); - EXIT_PRESENTER_FAILED - } - } - } +#[cfg(target_os = "linux")] +fn main() -> gtk::glib::ExitCode { + app::run() } -#[cfg(any(target_os = "linux", windows))] -fn main() -> std::process::ExitCode { - std::process::ExitCode::from(session_main::run()) -} - -/// This stub keeps `cargo build --workspace` green elsewhere (the Mac client lives in -/// clients/apple). -#[cfg(not(any(target_os = "linux", windows)))] +/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS +/// (the Mac client lives in clients/apple). +#[cfg(not(target_os = "linux"))] fn main() { - eprintln!( - "punktfunk-session runs on Linux and Windows — the macOS client lives in clients/apple" - ); + eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple"); std::process::exit(2); } diff --git a/clients/session/src/shortcuts.rs b/clients/session/src/shortcuts.rs new file mode 100644 index 00000000..ab856827 --- /dev/null +++ b/clients/session/src/shortcuts.rs @@ -0,0 +1,102 @@ +//! "Create shortcut…" — a desktop entry that boots straight into one host (optionally with a +//! profile or a game), design/client-deep-links.md §5. +//! +//! The shortcut is a **container for a URL**, not a second launch mechanism: it invokes the +//! client with a positional `punktfunk://…`, which is the same door xdg-open and a browser +//! prompt use. That is deliberate — it keeps working if scheme registration is lost or the +//! host store changes, because the URL itself carries the stable id, the address and the pin. +//! +//! Under flatpak the sandbox cannot write `~/.local/share/applications`, so this offers the +//! URL to copy instead. The `org.freedesktop.portal.DynamicLauncher` route (which exists for +//! exactly this, with its own confirmation) is the intended upgrade there. + +use std::path::PathBuf; + +/// Are we inside the flatpak sandbox? `/.flatpak-info` is present in every flatpak run and +/// nowhere else — the standard check, and the one the portal docs use. +pub fn sandboxed() -> bool { + std::path::Path::new("/.flatpak-info").exists() +} + +/// Write `~/.local/share/applications/punktfunk-.desktop` for this URL and return the +/// path. Best-effort `update-desktop-database` afterwards: an entry nothing indexes still +/// works from a file manager, it just won't show up in search straight away. +pub fn write_desktop_entry(label: &str, url: &str) -> Result { + let home = std::env::var("HOME").map_err(|_| "HOME isn't set".to_string())?; + let dir = PathBuf::from(home).join(".local/share/applications"); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let path = dir.join(format!("punktfunk-{}.desktop", file_slug(label))); + // Desktop-entry values are line-oriented: a newline in a host name would end the Name + // key and turn the rest into an unparsable line (or, worse, another key). + let name = one_line(label); + let entry = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name={name}\n\ + Comment=Stream from this Punktfunk host\n\ + Exec=punktfunk-client \"{url}\"\n\ + Icon=io.unom.Punktfunk\n\ + Terminal=false\n\ + Categories=Game;Network;\n\ + StartupNotify=true\n" + ); + std::fs::write(&path, entry).map_err(|e| format!("{}: {e}", path.display()))?; + // Some desktops only offer a `.desktop` as a launchable icon when it is executable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)); + } + let _ = std::process::Command::new("update-desktop-database") + .arg(&dir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + Ok(path) +} + +/// A filename-safe slug: ASCII alphanumerics and `-`, everything else collapsed to one `-`, +/// capped so a long host+profile pair can't produce a name the filesystem rejects. +fn file_slug(label: &str) -> String { + let mut out = String::new(); + for c in label.chars() { + if c.is_ascii_alphanumeric() { + out.push(c.to_ascii_lowercase()); + } else if !out.ends_with('-') { + out.push('-'); + } + } + let trimmed = out.trim_matches('-'); + let capped: String = trimmed.chars().take(48).collect(); + if capped.is_empty() { + "host".to_string() + } else { + capped + } +} + +/// Flatten to one line — see [`write_desktop_entry`] on why a newline here is not cosmetic. +fn one_line(s: &str) -> String { + s.replace(['\n', '\r'], " ").trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Names become safe filenames, and a name that survives to `Name=` can't break the file. + #[test] + fn labels_are_sanitised_both_ways() { + assert_eq!(file_slug("Living Room PC"), "living-room-pc"); + assert_eq!(file_slug("Büro · Mac"), "b-ro-mac"); + assert_eq!(file_slug("Desk · Work"), "desk-work"); + assert_eq!(file_slug("////"), "host"); + assert_eq!(file_slug(""), "host"); + assert!(file_slug(&"x".repeat(200)).len() <= 48); + // The classic injection: a newline would end the Name key and start a new one. + assert_eq!( + one_line("Desk\nExec=rm -rf ~"), + "Desk Exec=rm -rf ~".to_string() + ); + } +} diff --git a/clients/session/src/spawn.rs b/clients/session/src/spawn.rs new file mode 100644 index 00000000..3530745f --- /dev/null +++ b/clients/session/src/spawn.rs @@ -0,0 +1,118 @@ +//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session` +//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of +//! punktfunk-planning `linux-client-rearchitecture.md`). What is left here is the +//! TRANSLATION: a [`ConnectRequest`] becomes a `ConnectPlan`, and the session's typed +//! lifecycle events become the [`AppMsg`]s the relm4 app consumes — spinner until +//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, exit code 3 + +//! `trust_rejected` routed to the re-pair PIN ceremony. +//! +//! Spawning, the argv, the stdout contract and the child handle live in +//! `pf_client_core::orchestrate` (design/client-architecture-split.md §3) — the WinUI shell +//! and the coming CLI spawn through the same code, so "what flags does a stream get" and +//! "what does ready mean" have exactly one answer. + +use crate::app::AppMsg; +use crate::ui_hosts::ConnectRequest; +use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent}; +use pf_client_core::trust::Settings; + +/// Spawn tunables beyond a plain connect. +#[derive(Debug, Default)] +pub struct SpawnOpts { + /// Handshake budget override (`--connect-timeout`) — the request-access flow passes + /// ~185 s because the host PARKS the connection until the operator approves. + pub connect_timeout_secs: Option, + /// Persist the host as *paired* once the child reports ready (request-access: the + /// operator's approval IS the pairing). Plain TOFU persists unpaired. + pub persist_paired: bool, + /// A cancel handle to arm (request-access's waiting dialog): killing the child is + /// the only abort a parked connect has. + pub cancel: Option, +} + +pub use orchestrate::{session_binary, CancelHandle}; + +/// Spawn the session binary for a connect with `fp_hex` pinned and translate its +/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert +/// rather than the store — the app persists it once the child reports ready (the child +/// connects pinned to it, so ready proves the host really holds that identity). +/// +/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` = +/// the spawn itself failed (binary missing?) — surfaced as a connect error. +pub fn spawn_session( + sender: relm4::Sender, + req: ConnectRequest, + fp_hex: String, + tofu: bool, + fullscreen_on_stream: bool, + opts: SpawnOpts, +) -> Result<(), String> { + // The plan this connect resolves to. A plain card click carries no `--profile`: it honors + // the host's own binding, which the session resolves through the same helper this shell + // would have used (design/client-settings-profiles.md §4.6) — passing it here would be a + // second source of truth for one decision. Only a "Connect with ▸" pick (or a URL's + // `profile=`) sets one, and it applies to this session alone. + // + // Two fields are deliberately not the shell's state yet, because nothing in the spawn path + // reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake` + // is false because this shell still runs its own dial-first wake fallback in `app.rs`. Both + // become real when the GTK connect path moves onto `ConnectOrchestrator` (arch-split C0). + let plan = ConnectPlan { + host: HostTarget { + name: req.name.clone(), + addr: req.addr.clone(), + port: req.port, + fp_hex: Some(fp_hex.clone()), + mac: req.mac.clone(), + id: None, + }, + launch: req.launch.as_ref().map(|(id, _)| id.clone()), + profile: None, + // A one-off pick rides the flag; without one the session resolves the host's own + // binding through the same helper this shell would have used. + profile_override: req.profile.clone(), + settings: Settings { + fullscreen_on_stream, + ..Default::default() + }, + wake: false, + connect_timeout_secs: opts.connect_timeout_secs, + tofu, + // The per-host clipboard decision, resolved here so the child doesn't look it up + // again — matched by address, the way every other per-host lookup matches. + clipboard: pf_client_core::trust::KnownHosts::load() + .hosts + .iter() + .any(|h| h.addr == req.addr && h.port == req.port && h.clipboard_sync), + }; + + let persist_paired = opts.persist_paired; + let (mut error, mut ended) = (None::<(String, bool)>, None::); + orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev { + SessionEvent::Ready => { + let _ = sender.send(AppMsg::SessionReady { + req: req.clone(), + fp_hex: fp_hex.clone(), + tofu, + persist_paired, + }); + } + SessionEvent::Error { + msg, + trust_rejected, + } => error = Some((msg, trust_rejected)), + SessionEvent::Ended(msg) => ended = Some(msg), + // The brain persists the window size; the shell has nothing to do with it. + SessionEvent::Window { .. } => {} + SessionEvent::Exited(code) => { + let _ = sender.send(AppMsg::SessionExited { + req: req.clone(), + code, + error: error.take(), + ended: ended.take(), + tofu, + }); + } + })?; + Ok(()) +} diff --git a/clients/session/src/ui_hosts.rs b/clients/session/src/ui_hosts.rs new file mode 100644 index 00000000..4a425f51 --- /dev/null +++ b/clients/session/src/ui_hosts.rs @@ -0,0 +1,1363 @@ +//! The hosts page as a relm4 component: adaptive card grids for saved (trusted/paired) +//! and mDNS-discovered hosts — avatar + name + `addr:port` + status pills, online pips, +//! dashed discovered cards, an overflow menu, an add-host dialog, and a connect-failure +//! banner. Cards are a [`FactoryVecDeque`]; both grids re-populate from one state +//! snapshot (known hosts on disk + the live advert map) on every change, so dedup and +//! the online pips stay consistent. Actions leave as typed [`HostsOutput`]s — the +//! callback bag and `Rc>` pokes of the pre-relm4 shell are gone. + +use crate::discovery::{self, DiscoveredHost, DiscoveryEvent}; +use crate::trust::{KnownHost, KnownHosts, Settings}; +use adw::prelude::*; +use gtk::{gio, glib}; +use relm4::factory::FactoryVecDeque; +use relm4::prelude::*; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +/// What the user asked to connect to. `fp_hex` comes from the mDNS TXT record when the +/// host was discovered (drives the trust decision *before* connecting); manual entries +/// have none. `pair_optional` is true ONLY when a discovered host advertised +/// `pair=optional` — the sole case in which the reduced-security TOFU path may be +/// offered; every other case mandates PIN pairing. +#[derive(Clone, Debug)] +pub struct ConnectRequest { + pub name: String, + pub addr: String, + pub port: u16, + pub fp_hex: Option, + pub pair_optional: bool, + /// A library title to launch on connect (`(library id, display name)`). + pub launch: Option<(String, String)>, + /// Wake-on-LAN MAC(s) for this host. Empty when none is known. + pub mac: Vec, + /// A ONE-OFF settings profile for this connect ("Connect with ▸ X"): `Some(id)` overrides + /// the host's binding for this launch, `Some("")` forces the global defaults on a bound + /// host, `None` honors the binding. It never rebinds anything — the host's default changes + /// only through an explicit "Default profile" pick (design/client-settings-profiles.md §5.2). + pub profile: Option, +} + +impl ConnectRequest { + /// The key the page tracks an in-flight connect under (the card that swaps its + /// avatar for a spinner): the fingerprint when known, else the address. + pub fn card_key(&self) -> String { + self.fp_hex + .clone() + .unwrap_or_else(|| format!("{}:{}", self.addr, self.port)) + } +} + +// --- The card factory --------------------------------------------------------------------- + +/// One card's full render input — rebuilt (clear + repopulate) on every state change, +/// exactly like the pre-relm4 full-grid rebuild (a handful of widgets; simpler than row +/// surgery and keeps every derived view consistent). +#[derive(Debug)] +pub struct HostCard { + kind: CardKind, + connecting: bool, +} + +#[derive(Debug)] +enum CardKind { + Saved { + host: KnownHost, + online: bool, + recent: bool, + library_enabled: bool, + /// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per + /// refresh rather than re-read per card. + profiles: Rc>, + /// `Some((id, name))` when this card is a PINNED host+profile pair rather than the + /// host's primary card (design §5.2a): a one-click shortcut for a profile the user + /// reaches for often. It is presentation state on the host record — never a second + /// host entry, which would fork pairing, WoL and renames. + pinned: Option<(String, String)>, + }, + Discovered(DiscoveredHost), +} + +#[derive(Debug)] +pub enum CardOutput { + Connect(ConnectRequest), + /// Set (or clear, with `None`) this host's DEFAULT settings profile — the explicit + /// rebinding act; a one-off connect never does this. + BindProfile { + fp_hex: String, + addr: String, + port: u16, + profile_id: Option, + }, + WakeConnect(ConnectRequest), + Pair(ConnectRequest), + SpeedTest(ConnectRequest), + Library(ConnectRequest), + /// Open the host edit sheet (name, profile binding, clipboard). + Edit { + fp_hex: String, + name: String, + }, + Forget { + fp_hex: String, + name: String, + }, + Wake { + mac: Vec, + addr: String, + }, + /// Put this card's `punktfunk://` URL on the clipboard. + CopyLink(String), + /// Write a desktop entry that launches this card's URL. + CreateShortcut { + label: String, + url: String, + }, + /// Add or remove a pinned host+profile card (design §5.2a). Presentation only — it never + /// changes the host's default profile, and unpinning never touches the profile itself. + TogglePin { + fp_hex: String, + addr: String, + port: u16, + profile_id: String, + pin: bool, + }, +} + +impl HostCard { + fn request(&self) -> ConnectRequest { + match &self.kind { + CardKind::Saved { + host: k, pinned, .. + } => ConnectRequest { + name: k.name.clone(), + addr: k.addr.clone(), + port: k.port, + fp_hex: Some(k.fp_hex.clone()), + // Saved host: its fp is already pinned → silent pinned connect. + pair_optional: false, + launch: None, + mac: k.mac.clone(), + // A pinned card IS its profile: clicking it connects with that one, without + // touching the host's default. + profile: pinned.as_ref().map(|(id, _)| id.clone()), + }, + CardKind::Discovered(a) => ConnectRequest { + name: a.name.clone(), + addr: a.addr.clone(), + port: a.port, + fp_hex: (!a.fp_hex.is_empty()).then(|| a.fp_hex.clone()), + // TOFU only when the host explicitly opts in with pair=optional. + pair_optional: a.pair == "optional", + launch: None, + mac: a.mac.clone(), + profile: None, + }, + } + } +} + +impl relm4::factory::FactoryComponent for HostCard { + type Init = HostCard; + type Input = (); + type Output = CardOutput; + type CommandOutput = (); + type ParentWidget = gtk::FlowBox; + type Root = gtk::Overlay; + type Widgets = (); + type Index = relm4::factory::DynamicIndex; + + fn init_model( + init: Self::Init, + _index: &Self::Index, + _sender: relm4::FactorySender, + ) -> Self { + init + } + + fn init_root(&self) -> Self::Root { + gtk::Overlay::new() + } + + fn init_widgets( + &mut self, + _index: &Self::Index, + overlay: Self::Root, + returned: >k::FlowBoxChild, + sender: relm4::FactorySender, + ) -> Self::Widgets { + let req = self.request(); + + // The shared scaffold: avatar (spinner while connecting) / name / addr / status. + let content = gtk::Box::new(gtk::Orientation::Vertical, 6); + if self.connecting { + let spinner = gtk::Spinner::new(); + spinner.set_size_request(48, 48); + spinner.start(); + spinner.set_halign(gtk::Align::Center); + content.append(&spinner); + } else { + let avatar = adw::Avatar::new(48, Some(&req.name), true); + avatar.set_halign(gtk::Align::Center); + content.append(&avatar); + } + let name_label = gtk::Label::new(Some(&req.name)); + name_label.add_css_class("heading"); + name_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + content.append(&name_label); + let addr_label = gtk::Label::new(Some(&format!("{}:{}", req.addr, req.port))); + addr_label.add_css_class("caption"); + addr_label.add_css_class("dim-label"); + addr_label.add_css_class("numeric"); + addr_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + content.append(&addr_label); + + let status = gtk::Box::new(gtk::Orientation::Horizontal, 6); + status.set_halign(gtk::Align::Center); + status.set_margin_top(4); + let pill = |text: &str, class: &str| { + let l = gtk::Label::new(Some(text)); + l.add_css_class("pf-pill"); + l.add_css_class(class); + l + }; + match &self.kind { + CardKind::Saved { + host: k, + online, + profiles, + pinned, + .. + } => { + // Presence pip + spelled-out state, then the trust pill. + let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0); + pip.add_css_class("pf-pip"); + if *online { + pip.add_css_class("pf-online"); + } + pip.set_valign(gtk::Align::Center); + status.append(&pip); + let presence = gtk::Label::new(Some(if *online { "Online" } else { "Offline" })); + presence.add_css_class("caption"); + presence.add_css_class("dim-label"); + status.append(&presence); + status.append(&if k.paired { + pill("Paired", "pf-green") + } else { + pill("Trusted", "pf-accent") + }); + // The chip says what a plain click on THIS card will do: its own profile on a + // pinned card, the host's binding on the primary one. A binding whose profile + // was deleted shows nothing and resolves as the defaults, which is exactly + // what will happen on connect (design §6). + let chip = pinned.as_ref().map(|(_, name)| name.as_str()).or_else(|| { + k.profile_id + .as_ref() + .and_then(|id| profiles.iter().find(|(pid, _)| pid == id)) + .map(|(_, name)| name.as_str()) + }); + if let Some(name) = chip { + status.append(&pill( + name, + if pinned.is_some() { + "pf-accent" + } else { + "pf-neutral" + }, + )); + } + } + CardKind::Discovered(_) => { + status.append(&if req.pair_optional { + pill("Open", "pf-neutral") + } else { + pill("PIN", "pf-accent") + }); + } + } + content.append(&status); + + overlay.set_child(Some(&content)); + overlay.add_css_class("card"); + overlay.add_css_class("pf-host-card"); + if self.connecting { + returned.set_sensitive(false); + } + + match &self.kind { + CardKind::Saved { + host: k, + online, + recent, + library_enabled, + profiles, + pinned, + } => { + if *recent { + overlay.add_css_class("pf-recent"); + } + // Overflow menu (top-right; also on right-click). + let actions = gio::SimpleActionGroup::new(); + let add = |name: &str, out: Box CardOutput>| { + let a = gio::SimpleAction::new(name, None); + let sender = sender.clone(); + a.connect_activate(move |_, _| { + let _ = sender.output(out()); + }); + actions.add_action(&a); + }; + { + let req = req.clone(); + add("pair", Box::new(move || CardOutput::Pair(req.clone()))); + } + { + let req = req.clone(); + add( + "speed", + Box::new(move || CardOutput::SpeedTest(req.clone())), + ); + } + { + let req = req.clone(); + add( + "library", + Box::new(move || CardOutput::Library(req.clone())), + ); + } + { + let (fp, name) = (k.fp_hex.clone(), k.name.clone()); + add( + "rename", + Box::new(move || CardOutput::Edit { + fp_hex: fp.clone(), + name: name.clone(), + }), + ); + } + { + let (fp, name) = (k.fp_hex.clone(), k.name.clone()); + add( + "forget", + Box::new(move || CardOutput::Forget { + fp_hex: fp.clone(), + name: name.clone(), + }), + ); + } + { + let (mac, addr) = (k.mac.clone(), k.addr.clone()); + add( + "wake", + Box::new(move || CardOutput::Wake { + mac: mac.clone(), + addr: addr.clone(), + }), + ); + } + // Profiles: a ONE-OFF connect ("Connect with"), and the explicit rebinding + // act ("Default profile"). They are separate menus on purpose — the whole + // predictability rule is that connecting with a profile never changes what + // the card will do next time (design §5.2). + { + let profile_action = + |name: &str, out: Box) -> CardOutput>| { + let a = gio::SimpleAction::new(name, Some(glib::VariantTy::STRING)); + let sender = sender.clone(); + a.connect_activate(move |_, param| { + // The empty string is "Default settings" — a real choice, not + // an absent one, so it has to survive as a value. + let id = param.and_then(|p| p.str()).unwrap_or("").to_string(); + let _ = sender.output(out(Some(id).filter(|s| !s.is_empty()))); + }); + actions.add_action(&a); + }; + let req_for_connect = req.clone(); + profile_action( + "connect-with", + Box::new(move |id| { + let mut req = req_for_connect.clone(); + // `Some("")` — not `None` — so a bound host really does connect + // with the defaults when the user asks for them. + req.profile = Some(id.unwrap_or_default()); + CardOutput::Connect(req) + }), + ); + let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port); + profile_action( + "bind-profile", + Box::new(move |id| CardOutput::BindProfile { + fp_hex: fp.clone(), + addr: addr.clone(), + port, + profile_id: id, + }), + ); + // "Copy link": the self-emitted URL for this card, which is the pairing + // an external tool (a Playnite entry, a Stream Deck macro) is configured + // with. It carries the stable id AND host+fp, so it still resolves after a + // re-address or a reinstall (design/client-deep-links.md §2/§5). + { + let (host, profile) = (k.clone(), pinned.clone()); + let a = gio::SimpleAction::new("copy-link", None); + let sender = sender.clone(); + a.connect_activate(move |_, _| { + let url = pf_client_core::deeplink::DeepLink::for_host( + &host, + None, + profile.as_ref().map(|(id, _)| id.as_str()), + ) + .to_url(); + let _ = sender.output(CardOutput::CopyLink(url)); + }); + actions.add_action(&a); + } + // "Create shortcut…": the same URL as Copy link, wrapped in a desktop + // entry so it is double-clickable from the app grid. + { + let (host, profile) = (k.clone(), pinned.clone()); + let a = gio::SimpleAction::new("shortcut", None); + let sender = sender.clone(); + a.connect_activate(move |_, _| { + let url = pf_client_core::deeplink::DeepLink::for_host( + &host, + None, + profile.as_ref().map(|(id, _)| id.as_str()), + ) + .to_url(); + let label = match &profile { + Some((_, name)) => format!("{} \u{00b7} {name}", host.name), + None => host.name.clone(), + }; + let _ = sender.output(CardOutput::CreateShortcut { label, url }); + }); + actions.add_action(&a); + } + // The same action pins from a primary card and unpins from a pinned one — + // which of the two this card is decides the direction. + let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port); + let pinning = pinned.is_none(); + profile_action( + "toggle-pin", + Box::new(move |id| CardOutput::TogglePin { + fp_hex: fp.clone(), + addr: addr.clone(), + port, + profile_id: id.unwrap_or_default(), + pin: pinning, + }), + ); + } + overlay.insert_action_group("card", Some(&actions)); + + let menu = gio::Menu::new(); + if let Some((pin_id, pin_name)) = pinned { + // A pinned card is a shortcut, not a second host: it offers the one-offs + // and its own removal, and deliberately NOT pair/rename/forget — those + // belong to the host, and offering them here would blur what the card is. + let with = gio::Menu::new(); + for (label, target) in std::iter::once(("Default settings", "")).chain( + profiles + .iter() + .map(|(id, name)| (name.as_str(), id.as_str())), + ) { + let item = gio::MenuItem::new(Some(label), None); + item.set_action_and_target_value( + Some("card.connect-with"), + Some(&target.to_variant()), + ); + with.append_item(&item); + } + menu.append_submenu(Some("Connect with"), &with); + let unpin = gio::MenuItem::new( + Some(&format!("Unpin \u{201c}{pin_name}\u{201d}")), + None, + ); + unpin.set_action_and_target_value( + Some("card.toggle-pin"), + Some(&pin_id.as_str().to_variant()), + ); + menu.append_item(&unpin); + menu.append(Some("Copy link"), Some("card.copy-link")); + menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut")); + } else { + if !profiles.is_empty() { + let with = gio::Menu::new(); + let bind = gio::Menu::new(); + let pin = gio::Menu::new(); + for (label, id) in std::iter::once(("Default settings", "")).chain( + profiles + .iter() + .map(|(id, name)| (name.as_str(), id.as_str())), + ) { + // A checkmark would be the natural cue for the current binding, but + // a GMenu radio needs shared state per card; the bound profile is + // named on the card's chip instead, visible without opening a menu. + for (menu, action) in + [(&with, "card.connect-with"), (&bind, "card.bind-profile")] + { + let item = gio::MenuItem::new(Some(label), None); + item.set_action_and_target_value( + Some(action), + Some(&id.to_variant()), + ); + menu.append_item(&item); + } + // "Default settings" is not pinnable — the primary card is that. + if !id.is_empty() && !k.pinned_profiles.iter().any(|p| p == id) { + let item = gio::MenuItem::new(Some(label), None); + item.set_action_and_target_value( + Some("card.toggle-pin"), + Some(&id.to_variant()), + ); + pin.append_item(&item); + } + } + menu.append_submenu(Some("Connect with"), &with); + menu.append_submenu(Some("Default profile"), &bind); + if pin.n_items() > 0 { + menu.append_submenu(Some("Pin as card"), &pin); + } + } + menu.append(Some("Pair with PIN\u{2026}"), Some("card.pair")); + menu.append(Some("Test network speed\u{2026}"), Some("card.speed")); + // An explicit wake only when offline and a MAC is known. + if !online && !k.mac.is_empty() { + menu.append(Some("Wake host"), Some("card.wake")); + } + // Experimental (Preferences gate): browse the host's game library. + if *library_enabled { + menu.append(Some("Browse library\u{2026}"), Some("card.library")); + } + menu.append(Some("Copy link"), Some("card.copy-link")); + menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut")); + menu.append(Some("Edit\u{2026}"), Some("card.rename")); + menu.append(Some("Forget"), Some("card.forget")); + } + let menu_btn = gtk::MenuButton::builder() + .icon_name("view-more-symbolic") + .menu_model(&menu) + .halign(gtk::Align::End) + .valign(gtk::Align::Start) + .build(); + menu_btn.add_css_class("flat"); + overlay.add_overlay(&menu_btn); + let right_click = gtk::GestureClick::builder().button(3).build(); + { + let menu_btn = menu_btn.clone(); + right_click.connect_pressed(move |_, _, _, _| menu_btn.popup()); + } + overlay.add_controller(right_click); + + // Auto-wake: not advertising + a known MAC routes to WakeConnect, which + // dials first (a routed/Tailscale host is mDNS-blind, not asleep) and only + // falls into the wake-and-wait when the dial fails. + let wake_first = !online && !req.mac.is_empty(); + let sender = sender.clone(); + returned.connect_activate(move |_| { + let _ = sender.output(if wake_first { + CardOutput::WakeConnect(req.clone()) + } else { + CardOutput::Connect(req.clone()) + }); + }); + } + CardKind::Discovered(_) => { + overlay.add_css_class("pf-discovered"); + // Tap-to-connect only (parity with Android's discovered cards). + let sender = sender.clone(); + returned.connect_activate(move |_| { + let _ = sender.output(CardOutput::Connect(req.clone())); + }); + } + } + } +} + +// --- The page component --------------------------------------------------------------------- + +/// How long each saved-host reachability probe waits, and how often the sweep runs. The pip +/// reads `advertising OR probed-reachable`, so a host reached only over a routed network +/// (Tailscale/VPN) — which never appears on mDNS — still shows Online. +const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2500); +const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(12); + +/// The key a saved host is tracked under in the probe-results map: its fingerprint (stable +/// across IP changes) when it has one, else `addr:port` (a not-yet-paired manual entry). +fn saved_key(h: &KnownHost) -> String { + if h.fp_hex.is_empty() { + format!("{}:{}", h.addr, h.port) + } else { + h.fp_hex.clone() + } +} + +pub struct HostsPage { + adverts: HashMap, + /// Saved hosts proven reachable by the periodic QUIC probe (mDNS-independent), keyed by + /// [`saved_key`]. OR'd with live-advert presence to drive the Online pip. + probed: HashMap, + connecting: Option, + settings: Rc>, + saved: FactoryVecDeque, + discovered: FactoryVecDeque, + widgets: PageWidgets, +} + +struct PageWidgets { + stack: gtk::Stack, + banner: adw::Banner, + saved_heading: gtk::Label, + disc_heading: gtk::Label, + searching: gtk::Box, +} + +#[derive(Debug)] +pub enum HostsMsg { + /// A resolved mDNS advert (also the CI scenes' injection path). + Advert(DiscoveredHost), + AdvertRemoved { + fullname: String, + }, + /// Reload the disk store and re-render (fresh pairings, renames, the library gate). + Refresh, + /// A completed reachability sweep: saved-host key → reachable. Merged into the online pips. + Probed(HashMap), + /// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores. + SetConnecting(Option), + ShowError(String), + ClearError, + ShowAddHost, + /// Forwarded card actions (factory outputs). + Card(CardOutput), +} + +#[derive(Debug)] +pub enum HostsOutput { + Connect(ConnectRequest), + /// A one-line confirmation for the window's toast overlay. + Toast(String), + WakeConnect(ConnectRequest), + Pair(ConnectRequest), + SpeedTest(ConnectRequest), + /// With the advertised mgmt port when a live advert carries one. + Library(ConnectRequest, Option), +} + +impl SimpleComponent for HostsPage { + type Init = Rc>; + type Input = HostsMsg; + type Output = HostsOutput; + type Root = adw::NavigationPage; + type Widgets = (); + + fn init_root() -> Self::Root { + adw::NavigationPage::builder() + .title("Punktfunk") + .tag("hosts") + .build() + } + + fn init( + settings: Self::Init, + page: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let make_flow = || { + let f = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .activate_on_single_click(true) + .homogeneous(true) + .min_children_per_line(1) + .max_children_per_line(4) + .column_spacing(12) + .row_spacing(12) + .build(); + // Scopes the concentric hover-highlight radius (see app.rs CSS). + f.add_css_class("pf-host-grid"); + f + }; + let heading = |text: &str| { + let l = gtk::Label::new(Some(text)); + l.add_css_class("heading"); + l.set_halign(gtk::Align::Start); + l + }; + let saved_heading = heading("Saved hosts"); + let disc_heading = heading("On this network"); + + let saved = FactoryVecDeque::::builder() + .launch(make_flow()) + .forward(sender.input_sender(), HostsMsg::Card); + let discovered = FactoryVecDeque::::builder() + .launch(make_flow()) + .forward(sender.input_sender(), HostsMsg::Card); + + // A pointer click (and keyboard activate) emits `child-activated` on the + // *FlowBox*, never the child's own `activate` signal — bridge it back to the + // child, where each card wires its connect handler. The re-entrancy flag breaks + // the child-activated ↔ activate ping-pong that otherwise recurses forever + // (a real stack overflow on every card click; see the ignored display test). + for flow in [saved.widget(), discovered.widget()] { + let activating = std::cell::Cell::new(false); + flow.connect_child_activated(move |_, child| { + if activating.replace(true) { + return; + } + child.activate(); + activating.set(false); + }); + } + + // Shown under the discovered heading while no (unsaved) advert is live yet. + let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let spinner = gtk::Spinner::new(); + spinner.start(); + searching.append(&spinner); + let searching_label = gtk::Label::new(Some("Searching the LAN…")); + searching_label.add_css_class("dim-label"); + searching.append(&searching_label); + searching.set_margin_top(6); + searching.set_margin_bottom(6); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.set_margin_top(24); + content.set_margin_bottom(24); + content.set_margin_start(12); + content.set_margin_end(12); + content.append(&saved_heading); + content.append(saved.widget()); + content.append(&disc_heading); + content.append(&searching); + content.append(discovered.widget()); + + let clamp = adw::Clamp::builder() + .maximum_size(1100) + .child(&content) + .build(); + let scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&clamp) + .build(); + + // No saved hosts AND nothing on the LAN → the whole page is the empty state. + let empty = adw::StatusPage::builder() + .icon_name("network-workgroup-symbolic") + .title("No hosts yet") + .description( + "Hosts on your network appear here automatically.\nAdd one by address with +.", + ) + .build(); + let add_btn = gtk::Button::with_label("Add host"); + add_btn.add_css_class("pill"); + add_btn.add_css_class("suggested-action"); + add_btn.set_halign(gtk::Align::Center); + add_btn.set_action_name(Some("win.add-host")); + empty.set_child(Some(&add_btn)); + + let stack = gtk::Stack::new(); + stack.add_named(&scrolled, Some("grid")); + stack.add_named(&empty, Some("empty")); + + // Connect failures land here, not in toasts. + let banner = adw::Banner::new(""); + banner.set_button_label(Some("Dismiss")); + banner.connect_button_clicked(|b| b.set_revealed(false)); + + let header = adw::HeaderBar::new(); + let add_host_btn = gtk::Button::from_icon_name("list-add-symbolic"); + add_host_btn.set_tooltip_text(Some("Add host")); + add_host_btn.set_action_name(Some("win.add-host")); + header.pack_start(&add_host_btn); + let menu = gio::Menu::new(); + menu.append(Some("Preferences"), Some("win.preferences")); + menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); + menu.append(Some("About Punktfunk"), Some("win.about")); + let menu_btn = gtk::MenuButton::builder() + .icon_name("open-menu-symbolic") + .menu_model(&menu) + .primary(true) + .tooltip_text("Main menu") + .build(); + header.pack_end(&menu_btn); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.add_top_bar(&banner); + toolbar.set_content(Some(&stack)); + page.set_child(Some(&toolbar)); + + // Rebuilt every time the page is shown, so fresh TOFU/pairing entries appear on + // return. + { + let sender = sender.clone(); + page.connect_shown(move |_| sender.input(HostsMsg::Refresh)); + } + + // Stream mDNS adverts into the model; every add/remove re-evaluates both grids. + { + let rx = discovery::browse(); + let sender = sender.clone(); + glib::spawn_future_local(async move { + while let Ok(event) = rx.recv().await { + match event { + DiscoveryEvent::Resolved(h) => sender.input(HostsMsg::Advert(h)), + DiscoveryEvent::Removed { fullname } => { + sender.input(HostsMsg::AdvertRemoved { fullname }) + } + } + } + }); + } + + // Periodic reachability sweep: a saved host reached only over a routed network + // (Tailscale/VPN) never advertises on mDNS, so presence can't come from the advert map + // alone. Each cycle probes every saved host off the main thread (bounded, trust-agnostic + // QUIC handshake — the display-side companion to dial-first) and feeds results back as + // `Probed`; the first sweep runs immediately, then every `PROBE_INTERVAL`. + { + let sender = sender.clone(); + glib::spawn_future_local(async move { + loop { + let entries: Vec<(String, String, u16)> = KnownHosts::load() + .hosts + .iter() + .filter(|h| !h.addr.is_empty()) + .map(|h| (saved_key(h), h.addr.clone(), h.port)) + .collect(); + if !entries.is_empty() { + let (tx, rx) = async_channel::bounded(1); + std::thread::Builder::new() + .name("punktfunk-probe".into()) + .spawn(move || { + let targets = + entries.iter().map(|(_, a, p)| (a.clone(), *p)).collect(); + let results = + crate::trust::probe_reachable_many(targets, PROBE_TIMEOUT); + let map: HashMap = entries + .into_iter() + .map(|(k, _, _)| k) + .zip(results) + .collect(); + let _ = tx.send_blocking(map); + }) + .expect("spawn probe thread"); + if let Ok(map) = rx.recv().await { + sender.input(HostsMsg::Probed(map)); + } + } + glib::timeout_future(PROBE_INTERVAL).await; + } + }); + } + + let mut model = HostsPage { + adverts: HashMap::new(), + probed: HashMap::new(), + connecting: None, + settings, + saved, + discovered, + widgets: PageWidgets { + stack, + banner, + saved_heading, + disc_heading, + searching, + }, + }; + model.rebuild(); + + ComponentParts { model, widgets: () } + } + + fn update(&mut self, msg: HostsMsg, sender: ComponentSender) { + match msg { + HostsMsg::Advert(h) => { + self.adverts.insert(h.key.clone(), h); + self.rebuild(); + } + HostsMsg::AdvertRemoved { fullname } => { + self.adverts.retain(|_, a| a.fullname != fullname); + self.rebuild(); + } + HostsMsg::Refresh => self.rebuild(), + HostsMsg::Probed(map) => { + self.probed = map; + self.rebuild(); + } + HostsMsg::SetConnecting(key) => { + self.connecting = key; + self.rebuild(); + } + HostsMsg::ShowError(msg) => { + self.widgets.banner.set_title(&msg); + self.widgets.banner.set_revealed(true); + } + HostsMsg::ClearError => self.widgets.banner.set_revealed(false), + HostsMsg::ShowAddHost => self.add_host_dialog(&sender), + HostsMsg::Card(out) => match out { + CardOutput::Connect(req) => { + let _ = sender.output(HostsOutput::Connect(req)); + } + CardOutput::WakeConnect(req) => { + let _ = sender.output(HostsOutput::WakeConnect(req)); + } + CardOutput::Pair(req) => { + let _ = sender.output(HostsOutput::Pair(req)); + } + CardOutput::SpeedTest(req) => { + let _ = sender.output(HostsOutput::SpeedTest(req)); + } + CardOutput::Library(req) => { + let mgmt = self.mgmt_port_for(&req); + let _ = sender.output(HostsOutput::Library(req, mgmt)); + } + CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name), + CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name), + CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()), + CardOutput::BindProfile { + fp_hex, + addr, + port, + profile_id, + } => { + // Written straight onto the host record — the binding IS a field there, so + // there is no map to keep in step (design §4.1). Matched by fingerprint + // when there is one, else by address, like every other per-host lookup. + let mut known = KnownHosts::load(); + if let Some(h) = known.hosts.iter_mut().find(|h| { + (!fp_hex.is_empty() && h.fp_hex == fp_hex) + || (h.addr == addr && h.port == port) + }) { + h.profile_id = profile_id; + if let Err(e) = known.save() { + tracing::warn!(error = %format!("{e:#}"), "saving the profile binding"); + } + } + self.rebuild(); // the chip follows immediately + } + CardOutput::CopyLink(url) => { + if let Some(display) = gtk::gdk::Display::default() { + display.clipboard().set_text(&url); + } + let _ = sender.output(HostsOutput::Toast("Link copied".into())); + } + CardOutput::CreateShortcut { label, url } => { + self.shortcut_result(&sender, &label, &url); + } + CardOutput::TogglePin { + fp_hex, + addr, + port, + profile_id, + pin, + } => { + let mut known = KnownHosts::load(); + if let Some(h) = known.hosts.iter_mut().find(|h| { + (!fp_hex.is_empty() && h.fp_hex == fp_hex) + || (h.addr == addr && h.port == port) + }) { + h.pinned_profiles.retain(|p| p != &profile_id); + if pin { + h.pinned_profiles.push(profile_id); + } + if let Err(e) = known.save() { + tracing::warn!(error = %format!("{e:#}"), "saving the pinned cards"); + } + } + self.rebuild(); + } + }, + } + } +} + +impl HostsPage { + /// Re-populate both factories from disk + the advert map. Cheap (a handful of + /// widgets) and keeps every derived view — online pips, dedup, most-recent accent, + /// spinner — in one straight-line pass. + fn rebuild(&mut self) { + let known = KnownHosts::load(); + // A saved host is ONLINE iff a live advert matches it (fingerprint, or address + // when the advert carries no fp). + let matches = |k: &KnownHost, a: &DiscoveredHost| { + (!a.fp_hex.is_empty() && a.fp_hex == k.fp_hex) || (a.addr == k.addr && a.port == k.port) + }; + let most_recent = known + .hosts + .iter() + .filter_map(|h| h.last_used.map(|t| (h.fp_hex.clone(), t))) + .max_by_key(|&(_, t)| t) + .map(|(fp, _)| fp); + let library_enabled = self.settings.borrow().library_enabled; + // One catalog read per refresh, shared by every card's menus and chip. + let profiles: Rc> = Rc::new( + pf_client_core::profiles::ProfilesFile::load() + .profiles + .into_iter() + .map(|p| (p.id, p.name)) + .collect(), + ); + + { + let mut saved = self.saved.guard(); + saved.clear(); + for k in &known.hosts { + // Online = advertising on mDNS OR proven reachable by the last probe sweep. + let online = self.adverts.values().any(|a| matches(k, a)) + || self.probed.get(&saved_key(k)).copied().unwrap_or(false); + // Learn this host's wake MAC(s) from its live advert while it's online. + if let Some(a) = self + .adverts + .values() + .find(|a| matches(k, a) && !a.mac.is_empty()) + { + crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac); + } + saved.push_back(HostCard { + connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()), + kind: CardKind::Saved { + host: k.clone(), + online, + profiles: profiles.clone(), + recent: most_recent.as_deref() == Some(k.fp_hex.as_str()), + library_enabled, + pinned: None, + }, + }); + // …then its pinned host+profile cards, in the order the user pinned them. + // They share the host's live status because they read the same record, and a + // pin whose profile is gone simply doesn't render (design §5.2a). + for id in &k.pinned_profiles { + let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else { + continue; + }; + saved.push_back(HostCard { + // The spinner belongs to whichever card was clicked; a pin has its own + // key so two cards for one host don't both spin. + connecting: false, + kind: CardKind::Saved { + host: k.clone(), + online, + profiles: profiles.clone(), + recent: false, + library_enabled, + pinned: Some((id.clone(), name.clone())), + }, + }); + } + } + } + + // The discovered grid only surfaces genuinely-new hosts: anything matching a + // saved entry renders as that saved card (with its pip now green) instead. + let mut fresh: Vec<&DiscoveredHost> = self + .adverts + .values() + .filter(|a| !known.hosts.iter().any(|k| matches(k, a))) + .collect(); + fresh.sort_by(|a, b| a.name.cmp(&b.name).then(a.key.cmp(&b.key))); + let have_disc = !fresh.is_empty(); + { + let mut discovered = self.discovered.guard(); + discovered.clear(); + for a in fresh { + let key = if a.fp_hex.is_empty() { + format!("{}:{}", a.addr, a.port) + } else { + a.fp_hex.clone() + }; + discovered.push_back(HostCard { + connecting: self.connecting.as_deref() == Some(key.as_str()), + kind: CardKind::Discovered(a.clone()), + }); + } + } + + let have_saved = !known.hosts.is_empty(); + let w = &self.widgets; + w.saved_heading.set_visible(have_saved); + self.saved.widget().set_visible(have_saved); + w.disc_heading.set_visible(true); + self.discovered.widget().set_visible(have_disc); + w.searching.set_visible(!have_disc); + w.stack.set_visible_child_name(if have_saved || have_disc { + "grid" + } else { + "empty" + }); + } + + /// The advertised mgmt port for the host `req` points at, when a matching live + /// advert carries the `mgmt` TXT. + fn mgmt_port_for(&self, req: &ConnectRequest) -> Option { + self.adverts + .values() + .find(|a| { + req.fp_hex + .as_deref() + .is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp) + || (a.addr == req.addr && a.port == req.port) + }) + .and_then(|a| a.mgmt_port) + } + + /// Rename a saved host — an entry in an alert, then upsert + refresh. + /// Write the shortcut, or — inside the flatpak sandbox, which cannot reach + /// `~/.local/share/applications` — hand the user the URL to place themselves. The + /// DynamicLauncher portal is the intended upgrade for that case (design §5); until then + /// the fallback is the one the design already sanctions, not a dead end. + fn shortcut_result(&self, sender: &ComponentSender, label: &str, url: &str) { + if crate::shortcuts::sandboxed() { + let dialog = adw::AlertDialog::new( + Some("Create Shortcut"), + Some( + "Punktfunk is sandboxed here, so it can't add the shortcut itself. Copy \ + this link and make a launcher for it \u{2014} it opens the same stream.", + ), + ); + let entry = gtk::Entry::builder().text(url).editable(false).build(); + dialog.set_extra_child(Some(&entry)); + dialog.add_responses(&[("close", "Close"), ("copy", "Copy link")]); + dialog.set_response_appearance("copy", adw::ResponseAppearance::Suggested); + dialog.set_close_response("close"); + { + let url = url.to_string(); + dialog.connect_response(Some("copy"), move |_, _| { + if let Some(display) = gtk::gdk::Display::default() { + display.clipboard().set_text(&url); + } + }); + } + dialog.present(Some(&self.widgets.stack)); + return; + } + let msg = match crate::shortcuts::write_desktop_entry(label, url) { + Ok(_) => format!("Shortcut for \u{201c}{label}\u{201d} added to your applications"), + Err(e) => { + tracing::warn!(error = %e, "writing the shortcut"); + format!("Couldn't create the shortcut \u{2014} {e}") + } + }; + let _ = sender.output(HostsOutput::Toast(msg)); + } + + /// The host edit sheet — the per-host settings that are properties of the HOST, not of + /// the stream: its name, whether this machine shares its clipboard with it, and which + /// settings profile it defaults to. + /// + /// Linux had only "Rename" until now; the clipboard toggle in particular existed in the + /// store and on the Apple and Windows clients but had no Linux surface at all, so a Linux + /// user could not turn on a feature they were already paying the storage for. + fn edit_host_dialog(&self, sender: &ComponentSender, fp_hex: &str, current: &str) { + let stored = KnownHosts::load() + .hosts + .iter() + .find(|h| h.fp_hex == fp_hex) + .cloned(); + let name_row = adw::EntryRow::builder().title("Name").build(); + name_row.set_text(current); + let clipboard_row = adw::SwitchRow::builder() + .title("Share clipboard") + .subtitle( + "Copy and paste between this machine and that host. Per host \u{2014} handing a \ + host your clipboard is a decision about that host.", + ) + .build(); + clipboard_row.set_active(stored.as_ref().is_some_and(|h| h.clipboard_sync)); + + // Profile picker: "Default settings" plus the catalog, seeded to the current binding. + let catalog = pf_client_core::profiles::ProfilesFile::load(); + let mut labels = vec!["Default settings".to_string()]; + let mut ids: Vec = vec![String::new()]; + for p in &catalog.profiles { + labels.push(p.name.clone()); + ids.push(p.id.clone()); + } + let bound = stored.as_ref().and_then(|h| h.profile_id.clone()); + // A binding whose profile is gone reads as Default settings and is cleaned up on save + // — the same "dangling resolves as none" rule the connect path follows. + let selected = bound + .as_ref() + .and_then(|id| ids.iter().position(|i| i == id)) + .unwrap_or(0); + let profile_row = adw::ComboRow::builder() + .title("Profile") + .subtitle("The settings a plain click uses for this host") + .model(>k::StringList::new( + &labels.iter().map(String::as_str).collect::>(), + )) + .build(); + profile_row.set_selected(selected as u32); + + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + list.append(&name_row); + list.append(&profile_row); + list.append(&clipboard_row); + + let dialog = adw::AlertDialog::new(Some("Edit Host"), None); + dialog.set_extra_child(Some(&list)); + dialog.add_responses(&[("cancel", "Cancel"), ("save", "Save")]); + dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("save")); + dialog.set_close_response("cancel"); + { + let sender = sender.clone(); + let fp = fp_hex.to_string(); + dialog.connect_response(Some("save"), move |_, _| { + let name = name_row.text().trim().to_string(); + let mut known = KnownHosts::load(); + if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) { + if !name.is_empty() { + h.name = name; + } + h.clipboard_sync = clipboard_row.is_active(); + h.profile_id = ids + .get(profile_row.selected() as usize) + .filter(|id| !id.is_empty()) + .cloned(); + let _ = known.save(); + } + sender.input(HostsMsg::Refresh); + }); + } + dialog.present(Some(&self.widgets.stack)); + } + + /// Forget this host (drops the pinned fingerprint — a later connect re-pairs). + fn forget_dialog(&self, sender: &ComponentSender, fp_hex: &str, name: &str) { + let dialog = adw::AlertDialog::new( + Some("Remove saved host?"), + Some(&format!( + "Forget “{name}”? You'll need to pair (or trust) it again to reconnect." + )), + ); + dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]); + dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + { + let sender = sender.clone(); + let fp = fp_hex.to_string(); + dialog.connect_response(Some("remove"), move |_, _| { + let mut known = KnownHosts::load(); + known.remove_by_fp(&fp); + let _ = known.save(); + sender.input(HostsMsg::Refresh); + }); + } + dialog.present(Some(&self.widgets.stack)); + } + + /// "+": name (optional) / address / port. Submit runs the normal trust gate. + fn add_host_dialog(&self, sender: &ComponentSender) { + let list = gtk::ListBox::new(); + list.add_css_class("boxed-list"); + list.set_selection_mode(gtk::SelectionMode::None); + let name_row = adw::EntryRow::builder().title("Name (optional)").build(); + let addr_row = adw::EntryRow::builder().title("Address").build(); + let port_row = adw::EntryRow::builder().title("Port").text("9777").build(); + list.append(&name_row); + list.append(&addr_row); + list.append(&port_row); + list.set_size_request(320, -1); + + let dialog = adw::AlertDialog::new(Some("Add Host"), None); + dialog.set_extra_child(Some(&list)); + dialog.add_responses(&[("cancel", "Cancel"), ("connect", "Connect")]); + dialog.set_response_appearance("connect", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("connect")); + dialog.set_close_response("cancel"); + dialog.set_response_enabled("connect", false); + { + let dialog = dialog.clone(); + addr_row.connect_changed(move |row| { + dialog.set_response_enabled("connect", !row.text().trim().is_empty()); + }); + } + { + let sender = sender.clone(); + let (name_row, addr_row, port_row) = + (name_row.clone(), addr_row.clone(), port_row.clone()); + dialog.connect_response(Some("connect"), move |_, _| { + let text = addr_row.text().trim().to_string(); + if text.is_empty() { + return; + } + // A pasted `host:port` wins over the port field; else the field. + let (addr, port) = match text.rsplit_once(':') { + Some((a, p)) if p.parse::().is_ok() => { + (a.to_string(), p.parse::().unwrap()) + } + _ => ( + text.clone(), + port_row.text().trim().parse::().unwrap_or(9777), + ), + }; + let name = name_row.text().trim().to_string(); + let _ = sender.output(HostsOutput::Connect(ConnectRequest { + name: if name.is_empty() { addr.clone() } else { name }, + addr, + port, + fp_hex: None, + // Manual entry carries no advertised policy — never TOFU-eligible. + pair_optional: false, + launch: None, + mac: Vec::new(), + profile: None, + })); + }); + } + dialog.present(Some(&self.widgets.stack)); + } +} + +#[cfg(test)] +mod tests { + use adw::prelude::*; + use std::cell::Cell; + use std::rc::Rc; + + // Reproduces the exact FlowBox/FlowBoxChild wiring from `init()`: `child-activated` + // bridges to `child.activate()`, whose own default handler re-emits + // `child-activated` — that ping-pong recursed forever (stack overflow on every + // host-card click/Enter) until the re-entrancy guard was added. + #[test] + #[ignore = "needs a Wayland/X display"] + fn flow_box_activation_bridge_does_not_recurse() { + assert!(gtk::init().is_ok(), "no display"); + + let flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .activate_on_single_click(true) + .build(); + let activating = Cell::new(false); + flow.connect_child_activated(move |_, child| { + if activating.replace(true) { + return; + } + child.activate(); + activating.set(false); + }); + + let child = gtk::FlowBoxChild::new(); + flow.insert(&child, -1); + let fired = Rc::new(Cell::new(0u32)); + { + let fired = fired.clone(); + child.connect_activate(move |_| fired.set(fired.get() + 1)); + } + + flow.emit_by_name::<()>("child-activated", &[&child]); + + assert_eq!( + fired.get(), + 1, + "the per-card handler should fire exactly once" + ); + } +} diff --git a/clients/session/src/ui_library.rs b/clients/session/src/ui_library.rs new file mode 100644 index 00000000..62c278e5 --- /dev/null +++ b/clients/session/src/ui_library.rs @@ -0,0 +1,365 @@ +//! The game-library page (the Apple `LibraryView` ported): a poster grid of the host's +//! unified library fetched over the management API (`library.rs`), pushed onto the nav +//! stack from a saved card's "Browse library…" action. Poster art loads asynchronously +//! (worker threads → texture on the main loop) with a monogram placeholder, and tapping +//! a title starts a session that asks the host to launch it (the library id rides the +//! Hello via `ConnectRequest::launch`). + +use crate::app::{AppModel, AppMsg}; +use crate::library::{self, GameEntry}; +use crate::trust; +use crate::ui_hosts::ConnectRequest; +use adw::prelude::*; +use gtk::{gdk, glib}; +use relm4::prelude::*; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; + +/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/ +/// card activation); dropped when the page is popped, which also winds down any in-flight +/// art consumer (its weak upgrade fails). +struct State { + sender: ComponentSender, + identity: (String, String), + /// The advertised mgmt port when the host was live at open time (else the default). + mgmt_port: u16, + /// The host this library belongs to — cards clone it and add `launch`. + req: ConnectRequest, + stack: gtk::Stack, + flow: gtk::FlowBox, + error_page: adw::StatusPage, + /// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching. + art: RefCell>, + /// The Picture each entry currently renders into (rebuilt per render), so async art + /// results land on the right card. + pics: RefCell>, + /// Screenshot mode: render injected entries only, never touch the network. + mock: Cell, +} + +/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from +/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it). +pub fn open( + app: &AppModel, + sender: &ComponentSender, + req: ConnectRequest, + mgmt_port: Option, +) { + let state = build(&app.nav, app.identity.clone(), sender, req, mgmt_port); + load(&state); +} + +/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by +/// entry id) with no host and no network — the CI `library` scene. +pub fn open_mock( + nav: &adw::NavigationView, + identity: (String, String), + sender: &ComponentSender, + req: ConnectRequest, + games: Vec, + art: Vec<(String, gdk::Texture)>, +) { + let state = build(nav, identity, sender, req, None); + state.mock.set(true); + state.art.borrow_mut().extend(art); + if games.is_empty() { + state.stack.set_visible_child_name("empty"); + } else { + render(&state, &games); + state.stack.set_visible_child_name("grid"); + } +} + +/// Build the page (loading / error / empty / grid states in a stack) and push it. +fn build( + nav: &adw::NavigationView, + identity: (String, String), + sender: &ComponentSender, + req: ConnectRequest, + mgmt_port: Option, +) -> Rc { + let flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .activate_on_single_click(true) + .homogeneous(true) + .min_children_per_line(2) + .max_children_per_line(6) + .column_spacing(12) + .row_spacing(18) + .valign(gtk::Align::Start) + .build(); + // Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own + // `activate` — bridge it so each poster's connect handler (below) runs on click. + flow.connect_child_activated(|_, child| { + child.activate(); + }); + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.set_margin_top(24); + content.set_margin_bottom(24); + content.set_margin_start(12); + content.set_margin_end(12); + content.append(&flow); + let clamp = adw::Clamp::builder() + .maximum_size(1100) + .child(&content) + .build(); + let scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&clamp) + .build(); + + let loading = gtk::Box::new(gtk::Orientation::Vertical, 12); + loading.set_valign(gtk::Align::Center); + let spinner = gtk::Spinner::new(); + spinner.set_size_request(32, 32); + spinner.start(); + spinner.set_halign(gtk::Align::Center); + loading.append(&spinner); + let loading_label = gtk::Label::new(Some("Loading library…")); + loading_label.add_css_class("dim-label"); + loading.append(&loading_label); + + let error_page = adw::StatusPage::builder() + .icon_name("dialog-error-symbolic") + .title("Couldn't load the library") + .build(); + let retry = gtk::Button::with_label("Retry"); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_halign(gtk::Align::Center); + error_page.set_child(Some(&retry)); + + let empty = adw::StatusPage::builder() + .icon_name("applications-games-symbolic") + .title("No games found") + .description( + "No games found on this host. Install Steam titles or add custom \ + entries in the host's web console.", + ) + .build(); + + let stack = gtk::Stack::new(); + stack.add_named(&loading, Some("loading")); + stack.add_named(&error_page, Some("error")); + stack.add_named(&empty, Some("empty")); + stack.add_named(&scrolled, Some("grid")); + + let header = adw::HeaderBar::new(); + let reload = gtk::Button::from_icon_name("view-refresh-symbolic"); + reload.set_tooltip_text(Some("Reload")); + header.pack_end(&reload); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&stack)); + + let page = adw::NavigationPage::builder() + .title(format!("{} — Library", req.name)) + .child(&toolbar) + .build(); + + let state = Rc::new(State { + sender: sender.clone(), + identity, + mgmt_port: mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT), + req, + stack, + flow, + error_page, + art: RefCell::new(HashMap::new()), + pics: RefCell::new(HashMap::new()), + mock: Cell::new(false), + }); + { + let state = state.clone(); + reload.connect_clicked(move |_| load(&state)); + } + { + let state = state.clone(); + retry.connect_clicked(move |_| load(&state)); + } + nav.push(&page); + state +} + +/// Fetch the library off the main thread and route the result into the grid or the +/// error/empty states. +fn load(state: &Rc) { + if state.mock.get() { + return; // screenshot scene renders injected entries only + } + state.stack.set_visible_child_name("loading"); + let port = state.mgmt_port; + let addr = state.req.addr.clone(); + let identity = state.identity.clone(); + let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); + let (tx, rx) = async_channel::bounded(1); + std::thread::Builder::new() + .name("punktfunk-library".into()) + .spawn(move || { + let _ = tx.send_blocking(library::fetch_games(&addr, port, &identity, pin)); + }) + .expect("spawn library thread"); + let weak = Rc::downgrade(state); + glib::spawn_future_local(async move { + let Ok(result) = rx.recv().await else { return }; + let Some(state) = weak.upgrade() else { return }; + match result { + Ok(games) if games.is_empty() => state.stack.set_visible_child_name("empty"), + Ok(games) => { + render(&state, &games); + state.stack.set_visible_child_name("grid"); + load_art(&state, &games); + } + Err(e) => { + state.error_page.set_description(Some(&e.to_string())); + state.stack.set_visible_child_name("error"); + } + } + }); +} + +/// (Re)build the poster grid from one library snapshot. Cached textures apply +/// immediately; the rest keep their monogram placeholder until `load_art` delivers. +fn render(state: &Rc, games: &[GameEntry]) { + state.flow.remove_all(); + state.pics.borrow_mut().clear(); + for game in games { + state.flow.append(&game_card(state, game)); + } +} + +/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a +/// monogram placeholder underneath the async art. Activation starts a session launching +/// this title (silent on a pinned host — the normal trust gate applies). +fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { + let monogram = gtk::Label::new(Some(&initials(&game.title))); + monogram.add_css_class("pf-poster-monogram"); + monogram.set_halign(gtk::Align::Center); + monogram.set_valign(gtk::Align::Center); + let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0); + placeholder.append(&monogram); + monogram.set_vexpand(true); + + let pic = gtk::Picture::new(); + pic.set_content_fit(gtk::ContentFit::Cover); + if let Some(tex) = state.art.borrow().get(&game.id) { + pic.set_paintable(Some(tex)); + } + state.pics.borrow_mut().insert(game.id.clone(), pic.clone()); + + let badge = gtk::Label::new(Some(store_label(&game.store))); + badge.add_css_class("pf-pill"); + badge.add_css_class("pf-store-badge"); + badge.set_halign(gtk::Align::Start); + badge.set_valign(gtk::Align::Start); + badge.set_margin_start(6); + badge.set_margin_top(6); + + let poster = gtk::Overlay::new(); + poster.set_child(Some(&placeholder)); + poster.add_overlay(&pic); + poster.add_overlay(&badge); + poster.add_css_class("pf-poster"); + poster.set_overflow(gtk::Overflow::Hidden); + poster.set_size_request(150, 225); + poster.set_halign(gtk::Align::Center); + + let title = gtk::Label::new(Some(&game.title)); + title.add_css_class("caption"); + title.set_ellipsize(gtk::pango::EllipsizeMode::End); + title.set_max_width_chars(16); + title.set_tooltip_text(Some(&game.title)); + + let card = gtk::Box::new(gtk::Orientation::Vertical, 6); + card.append(&poster); + card.append(&title); + + let child = gtk::FlowBoxChild::new(); + child.set_child(Some(&card)); + let sender = state.sender.clone(); + let mut req = state.req.clone(); + req.launch = Some((game.id.clone(), game.title.clone())); + child.connect_activate(move |_| sender.input(AppMsg::Connect(req.clone()))); + child +} + +/// Fetch poster art for every uncached entry on a small worker pool, walking each +/// entry's candidates in the Apple fallback order (portrait → header → hero) and +/// texturing the first that loads on the main loop. +fn load_art(state: &Rc, games: &[GameEntry]) { + let base = library::base_url(&state.req.addr, state.mgmt_port); + let jobs: VecDeque<(String, Vec)> = { + let cache = state.art.borrow(); + games + .iter() + .filter(|g| !cache.contains_key(&g.id)) + .map(|g| (g.id.clone(), g.art.poster_candidates(&base))) + .filter(|(_, candidates)| !candidates.is_empty()) + .collect() + }; + if jobs.is_empty() { + return; + } + let identity = state.identity.clone(); + let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); + let rx = library::spawn_art_fetch(base, identity, pin, jobs); + let weak = Rc::downgrade(state); + glib::spawn_future_local(async move { + while let Ok((id, bytes)) = rx.recv().await { + let Some(state) = weak.upgrade() else { break }; + // Texture decode happens here on the main loop — posters are small (tens of + // KB), and `from_bytes` handles jpeg/png alike. + match gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) { + Ok(tex) => { + if let Some(pic) = state.pics.borrow().get(&id) { + pic.set_paintable(Some(&tex)); + } + state.art.borrow_mut().insert(id, tex); + } + Err(e) => tracing::debug!(%id, error = %e, "undecodable poster"), + } + } + }); +} + +/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future +/// stores per the host's provider list), with the id prefix as a fallback spelling. +/// Shared with the gamepad launcher's posters. +pub fn store_label(store: &str) -> &'static str { + match store { + "steam" => "Steam", + "custom" => "Custom", + "heroic" => "Heroic", + "lutris" => "Lutris", + "epic" => "Epic", + "gog" => "GOG", + "xbox" => "Xbox", + _ => "Game", + } +} + +/// Monogram for the placeholder tile: the first letters of the first two words. +/// Shared with the gamepad launcher's posters. +pub fn initials(title: &str) -> String { + title + .split_whitespace() + .take(2) + .filter_map(|w| w.chars().next()) + .flat_map(char::to_uppercase) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initials_take_two_words() { + assert_eq!(initials("Dota 2"), "D2"); + assert_eq!(initials("half-life"), "H"); + assert_eq!(initials("The Witness III"), "TW"); + assert_eq!(initials(""), ""); + } +} diff --git a/clients/session/src/ui_settings.rs b/clients/session/src/ui_settings.rs new file mode 100644 index 00000000..6842eeb5 --- /dev/null +++ b/clients/session/src/ui_settings.rs @@ -0,0 +1,1769 @@ +//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings +//! revamp): General / Display / Input / Audio / Controllers pages — Display owns +//! everything about the picture — with per-field captions in each row's subtitle, +//! dynamic where the meaning depends on the selection (touch mode). Written back to +//! disk when the dialog closes. About stays in the primary menu (GNOME convention) +//! rather than as a page. +//! +//! The same surface edits SETTINGS PROFILES (design/client-settings-profiles.md §5.1): a +//! scope switcher at the top swaps the whole dialog between the global defaults and one +//! profile's overrides. It is deliberately not a second editor — a parallel one would drift +//! from this one field by field. In profile scope only profileable ("tier P") rows render, +//! every row shows the EFFECTIVE value (the inherited global until you touch it), and the +//! override is recorded on touch rather than by comparing values, so a profile can pin a +//! value that happens to equal today's global and keep it when the global later moves. + +use crate::trust::Settings; +use adw::prelude::*; +use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile}; +use pf_client_core::trust::StatsVerbosity; +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use std::rc::Rc; + +/// Which layer the dialog is editing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Scope { + /// The global defaults every profile inherits from — the only scope before this feature. + Defaults, + /// One profile's overrides, by id. + Profile(String), +} + +/// Which rows the user actually touched this session. The override model is explicit, not +/// diffed: touching a control creates the override, and only an explicit reset removes it +/// (design §4.1). Keys are the overlay's field names, with `resolution` covering the +/// width/height/match-window tri-state that one row drives. +#[derive(Clone, Default)] +struct Touched(Rc>>); + +impl Touched { + fn mark(&self, key: &'static str) { + self.0.borrow_mut().insert(key); + } + + fn has(&self, key: &str) -> bool { + self.0.borrow().contains(key) + } + + /// Undo a touch — the user reset this row after changing it, and "back to inheriting" is + /// the later, explicit intent. + fn forget(&self, key: &str) { + self.0.borrow_mut().remove(key); + } +} + +/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect. +const RESOLUTIONS: &[(u32, u32)] = &[ + (0, 0), + (1280, 720), + (1920, 1080), + (2560, 1440), + (3840, 2160), +]; +/// `0` = the monitor's native refresh, resolved at connect. +const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; +/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]). +/// `1.0` = Native. Applied at connect and each match-window resize. +const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; + +/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a +/// full colour picker: the point is telling profiles apart at a glance on a host card, which a +/// small set of legible, contrast-checked colours does better than free choice — and the +/// schema's `#RRGGBB` still accepts anything a future picker or a hand-edit writes. +const SWATCHES: &[(&str, &str, &str)] = &[ + ("", "pf-swatch-none", "No colour"), + ("#e01b24", "pf-swatch-red", "Red"), + ("#ff7800", "pf-swatch-orange", "Orange"), + ("#f6d32d", "pf-swatch-yellow", "Yellow"), + ("#33d17a", "pf-swatch-green", "Green"), + ("#3584e4", "pf-swatch-blue", "Blue"), + ("#9141ac", "pf-swatch-purple", "Purple"), + ("#d16d9e", "pf-swatch-pink", "Pink"), + ("#77767b", "pf-swatch-slate", "Slate"), +]; + +/// A row of colour swatches, calling back with the chosen `#RRGGBB` (empty = none). Used both +/// when creating a profile and when changing one later. +fn swatch_row(current: Option<&str>, on_pick: impl Fn(String) + 'static) -> gtk::Box { + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + let on_pick = Rc::new(on_pick); + let buttons: Rc>> = Rc::default(); + for (hex, class, name) in SWATCHES { + let b = gtk::Button::builder() + .css_classes(["pf-swatch", class, "circular"]) + .tooltip_text(*name) + .valign(gtk::Align::Center) + .build(); + if current.unwrap_or("") == *hex { + b.add_css_class("pf-swatch-on"); + } + { + let (on_pick, buttons, hex) = (on_pick.clone(), buttons.clone(), hex.to_string()); + b.connect_clicked(move |me| { + // The selection ring is exclusive, so clear every sibling before setting ours. + for (_, other) in buttons.borrow().iter() { + other.remove_css_class("pf-swatch-on"); + } + me.add_css_class("pf-swatch-on"); + on_pick(hex.clone()); + }); + } + buttons.borrow_mut().push((hex.to_string(), b.clone())); + row.append(&b); + } + row +} + +/// The scope switcher, plus (in profile scope) that profile's management actions. +/// +/// Switching scope does not swap the rows in place: it closes the dialog — which commits the +/// layer being edited — and asks the app to re-open in the new scope. One code path builds +/// the rows, and the commit ordering is unambiguous. +#[allow(clippy::too_many_arguments)] +fn scope_group( + dialog: &adw::PreferencesDialog, + inline: bool, + scope: &Scope, + catalog: &ProfilesFile, + active: Option<&StreamProfile>, + next_scope: &Rc>>, + parent: &impl IsA, +) -> adw::PreferencesGroup { + let g = group( + "", + "A profile overrides only what you change here; everything else follows Default \ + settings.", + ); + let mut labels: Vec = vec!["Default settings".into()]; + labels.extend(catalog.profiles.iter().map(|p| p.name.clone())); + labels.push("New profile…".into()); + let new_index = (labels.len() - 1) as u32; + let current = match scope { + Scope::Defaults => 0, + Scope::Profile(id) => catalog + .profiles + .iter() + .position(|p| &p.id == id) + .map_or(0, |i| i as u32 + 1), + }; + let row = ChoiceRow::new( + dialog, + inline, + "Editing", + "Which layer these settings belong to", + &labels.iter().map(String::as_str).collect::>(), + ); + row.set_selected(current); + { + let (dialog, next, parent) = (dialog.clone(), next_scope.clone(), parent.as_ref().clone()); + let ids: Vec = catalog.profiles.iter().map(|p| p.id.clone()).collect(); + row.connect_changed(move |i| { + if i == new_index { + // Creation is the one branch that has to ask a question first; the switch + // happens in its callback, so a cancelled prompt leaves the dialog put. + let (dialog, next) = (dialog.clone(), next.clone()); + prompt_name( + &parent, + "New profile", + "Create", + "", + true, + move |name, accent| { + let mut catalog = ProfilesFile::load(); + if catalog.name_taken(&name, None) { + return; // the prompt already refuses these; belt and braces + } + let mut profile = StreamProfile::new(name); + profile.accent = accent; + let id = profile.id.clone(); + catalog.profiles.push(profile); + if catalog.save().is_ok() { + *next.borrow_mut() = Some(Scope::Profile(id)); + dialog.close(); + } + }, + ); + return; + } + *next.borrow_mut() = Some(match i { + 0 => Scope::Defaults, + n => match ids.get(n as usize - 1) { + Some(id) => Scope::Profile(id.clone()), + None => Scope::Defaults, + }, + }); + dialog.close(); + }); + } + g.add(row.widget()); + // Leaking the row keeps its handler alive for the dialog's lifetime — the ChoiceRow owns + // the closure, and the widget alone doesn't keep it. + std::mem::forget(row); + + if let Some(active) = active { + // Colour first: it is what the profile's chips carry on host cards, so it belongs with + // the profile's identity rather than buried behind a menu. + let colour = adw::ActionRow::builder() + .title("Colour") + .subtitle("Tints this profile's chips on host cards") + .use_markup(false) + .build(); + colour.add_suffix(&swatch_row(active.accent.as_deref(), { + let id = active.id.clone(); + move |hex| { + let mut catalog = ProfilesFile::load(); + if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) { + p.accent = (!hex.is_empty()).then(|| hex.clone()); + if let Err(e) = catalog.save() { + tracing::warn!(error = %format!("{e:#}"), "saving the profile colour"); + } + } + } + })); + g.add(&colour); + let actions = adw::ActionRow::builder() + .title(&active.name) + .subtitle("This profile") + .use_markup(false) + .build(); + let buttons = gtk::Box::builder() + .spacing(6) + .valign(gtk::Align::Center) + .build(); + for (label, action) in [ + ("Rename…", ProfileAction::Rename), + ("Duplicate", ProfileAction::Duplicate), + ("Delete…", ProfileAction::Delete), + ] { + let b = gtk::Button::builder().label(label).build(); + if matches!(action, ProfileAction::Delete) { + b.add_css_class("destructive-action"); + } + let (dialog, next, parent, id, name) = ( + dialog.clone(), + next_scope.clone(), + parent.as_ref().clone(), + active.id.clone(), + active.name.clone(), + ); + b.connect_clicked(move |_| { + run_profile_action(action, &parent, &dialog, &next, &id, &name) + }); + buttons.append(&b); + } + actions.add_suffix(&buttons); + g.add(&actions); + } + g +} + +#[derive(Clone, Copy)] +enum ProfileAction { + Rename, + Duplicate, + Delete, +} + +/// Rename / duplicate / delete for the profile in scope. Each ends by closing the dialog so +/// the edit and the re-render can't disagree about what the catalog holds. +fn run_profile_action( + action: ProfileAction, + parent: >k::Widget, + dialog: &adw::PreferencesDialog, + next: &Rc>>, + id: &str, + name: &str, +) { + let (dialog, next, id) = (dialog.clone(), next.clone(), id.to_string()); + match action { + ProfileAction::Rename => { + let keep = id.clone(); + prompt_name( + parent, + "Rename profile", + "Rename", + name, + false, + move |new_name, _| { + let mut catalog = ProfilesFile::load(); + if catalog.name_taken(&new_name, Some(&keep)) { + return; + } + if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == keep) { + p.name = new_name; + } + if catalog.save().is_ok() { + *next.borrow_mut() = Some(Scope::Profile(keep.clone())); + dialog.close(); + } + }, + ); + } + ProfileAction::Duplicate => { + let mut catalog = ProfilesFile::load(); + let Some(source) = catalog.find_by_id(&id).cloned() else { + return; + }; + // "Work 2", "Work 3", … — the first name the catalog doesn't already hold. + let copy_name = (2..) + .map(|n| format!("{} {n}", source.name)) + .find(|n| !catalog.name_taken(n, None)) + .unwrap_or_else(|| source.name.clone()); + let mut copy = StreamProfile::new(copy_name); + copy.overrides = source.overrides.clone(); + copy.accent = source.accent.clone(); + let new_id = copy.id.clone(); + catalog.profiles.push(copy); + if catalog.save().is_ok() { + *next.borrow_mut() = Some(Scope::Profile(new_id)); + dialog.close(); + } + } + ProfileAction::Delete => { + // The warning counts what actually breaks: hosts that fall back to the defaults, + // and pinned cards that disappear (design §6). + let known = crate::trust::KnownHosts::load(); + let bound = known + .hosts + .iter() + .filter(|h| h.profile_id.as_deref() == Some(id.as_str())) + .count(); + let pinned = known + .hosts + .iter() + .filter(|h| h.pinned_profiles.iter().any(|p| p == &id)) + .count(); + let mut body = format!("“{name}” will be removed."); + if bound > 0 { + body.push_str(&format!( + "\n\n{bound} host{} will fall back to Default settings.", + if bound == 1 { "" } else { "s" } + )); + } + if pinned > 0 { + body.push_str(&format!( + "\n{pinned} pinned card{} will disappear.", + if pinned == 1 { "" } else { "s" } + )); + } + let confirm = adw::AlertDialog::new(Some("Delete profile?"), Some(&body)); + confirm.add_responses(&[("cancel", "Cancel"), ("delete", "Delete")]); + confirm.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + confirm.set_close_response("cancel"); + confirm.connect_response(Some("delete"), move |_, _| { + let mut catalog = ProfilesFile::load(); + catalog.profiles.retain(|p| p.id != id); + if catalog.save().is_ok() { + // Bindings and pins are left dangling on purpose: they resolve as "no + // profile" everywhere, and rewriting every host record here would be a + // second, racier source of truth. + *next.borrow_mut() = Some(Scope::Defaults); + dialog.close(); + } + }); + confirm.present(Some(parent)); + } + } +} + +/// A one-line name prompt (create/rename). Refuses empty and duplicate names in place — +/// menus keyed by name are ambiguous otherwise (design §6) — rather than failing after the +/// dialog is gone. +fn prompt_name( + parent: &impl IsA, + heading: &str, + accept: &str, + initial: &str, + with_colour: bool, + on_ok: impl Fn(String, Option) + 'static, +) { + let dialog = adw::AlertDialog::new(Some(heading), None); + let entry = adw::EntryRow::builder().title("Name").build(); + entry.set_text(initial); + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + list.append(&entry); + // Creating a profile picks its colour here, in the same breath as its name — going hunting + // for it afterwards is exactly the friction that leaves every profile grey. + let accent: Rc>> = Rc::default(); + if with_colour { + let row = adw::ActionRow::builder() + .title("Colour") + .use_markup(false) + .build(); + row.add_suffix(&swatch_row(None, { + let accent = accent.clone(); + move |hex| *accent.borrow_mut() = (!hex.is_empty()).then(|| hex.clone()) + })); + list.append(&row); + } + dialog.set_extra_child(Some(&list)); + dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]); + dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("ok")); + dialog.set_close_response("cancel"); + let taken_against = initial.to_string(); + let e = entry.clone(); + let d = dialog.clone(); + let validate = move || { + let name = e.text().trim().to_string(); + let catalog = ProfilesFile::load(); + let dup = !name.eq_ignore_ascii_case(&taken_against) && catalog.name_taken(&name, None); + d.set_response_enabled("ok", !name.is_empty() && !dup); + e.set_title(if dup { + "Name — already used by another profile" + } else { + "Name" + }); + }; + validate(); + { + let validate = validate.clone(); + entry.connect_changed(move |_| validate()); + } + let e = entry.clone(); + dialog.connect_response(Some("ok"), move |_, _| { + let name = e.text().trim().to_string(); + if !name.is_empty() { + on_ok(name, accent.borrow().clone()); + } + }); + dialog.present(Some(parent)); +} + +/// Write the rows the user touched into this profile's overlay and persist the catalog. +/// +/// Only touched fields move: an untouched row leaves whatever the profile already had +/// (an inherited `None`, or an existing override this build might not even render), which is +/// what keeps an older client from erasing a newer one's values just by opening the dialog. +/// The catalog is re-read here rather than reused, so a profile renamed in another window +/// between opening and closing this one survives. +fn commit_profile( + active: &StreamProfile, + touched: &Touched, + cleared: &HashSet<&'static str>, + values: &Settings, +) { + let mut catalog = ProfilesFile::load(); + let Some(slot) = catalog.profiles.iter_mut().find(|p| p.id == active.id) else { + return; // deleted from under us — nothing to write to, and nothing to complain about + }; + let o: &mut SettingsOverlay = &mut slot.overrides; + if touched.has("resolution") { + // One row drives the tri-state, so all three fields move together. + o.match_window = Some(values.match_window); + o.width = Some(values.width); + o.height = Some(values.height); + } + if touched.has("refresh_hz") { + o.refresh_hz = Some(values.refresh_hz); + } + if touched.has("render_scale") { + o.render_scale = Some(values.render_scale); + } + if touched.has("bitrate_kbps") { + o.bitrate_kbps = Some(values.bitrate_kbps); + } + if touched.has("codec") { + o.codec = Some(values.codec.clone()); + } + if touched.has("hdr_enabled") { + o.hdr_enabled = Some(values.hdr_enabled); + } + if touched.has("enable_444") { + o.enable_444 = Some(values.enable_444); + } + if touched.has("compositor") { + o.compositor = Some(values.compositor.clone()); + } + if touched.has("audio_channels") { + o.audio_channels = Some(values.audio_channels); + } + if touched.has("mic_enabled") { + o.mic_enabled = Some(values.mic_enabled); + } + if touched.has("touch_mode") { + o.touch_mode = Some(values.touch_mode.clone()); + } + if touched.has("mouse_mode") { + o.mouse_mode = Some(values.mouse_mode.clone()); + } + if touched.has("invert_scroll") { + o.invert_scroll = Some(values.invert_scroll); + } + if touched.has("inhibit_shortcuts") { + o.inhibit_shortcuts = Some(values.inhibit_shortcuts); + } + if touched.has("gamepad") { + o.gamepad = Some(values.gamepad.clone()); + } + if touched.has("stats_verbosity") { + o.stats_verbosity = Some(values.stats_verbosity()); + } + if touched.has("fullscreen_on_stream") { + o.fullscreen_on_stream = Some(values.fullscreen_on_stream); + } + // Resets last: a row the user reset may also have fired its change handler on the way + // (a ComboRow re-seeds), and "back to inheriting" is the later, explicit intent. The field + // names are the overlay's own, so the list of what can be cleared lives in ONE place — + // this used to be a second `match` here, which is exactly how the two drift. + for key in cleared { + if !o.clear(key) { + tracing::warn!(field = key, "reset of an unknown overlay field"); + } + } + if let Err(e) = catalog.save() { + tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog"); + } +} + +/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)". +fn render_scale_label(scale: f64) -> String { + if scale == 1.0 { + "Native".to_string() + } else if scale > 1.0 { + format!("{scale}× (supersample)") + } else { + format!("{scale}×") + } +} +const GAMEPADS: &[&str] = &[ + "auto", + "xbox360", + "dualsense", + "xboxone", + "dualshock4", + "steamdeck", +]; +const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; +/// Codec setting values (persisted) paired with their display labels below. PyroWave is +/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to +/// HEVC when either side can't do it. +const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"]; +const CODEC_LABELS: &[&str] = &[ + "Automatic", + "HEVC (H.265)", + "H.264 (AVC)", + "AV1", + "PyroWave (wired LAN)", +]; +const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"]; +/// Touch-input model values (persisted) paired with their display labels below — the +/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet). +const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"]; +const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"]; +/// The SELECTED touch mode explained — the caption swaps with the choice (the Apple +/// revamp's dynamic-caption idiom) instead of narrating all three modes at once. +/// Combo-row captions must stay ONE line (~66 chars at the default dialog width): a +/// wrapped subtitle's natural width crushes the selected-value label into an ellipsis. +const TOUCH_MODE_CAPTIONS: &[&str] = &[ + "Drives the cursor like a laptop trackpad — tap to click", + "The cursor jumps to your finger — a tap clicks there", + "Real multi-touch reaches the host — for touch-native apps", +]; +/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as +/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream. +const MOUSE_MODES: &[&str] = &["capture", "desktop"]; +const MOUSE_MODE_LABELS: &[&str] = &["Capture (games)", "Desktop (absolute)"]; +const MOUSE_MODE_CAPTIONS: &[&str] = &[ + "Pointer locks to the stream — relative motion, best for games", + "Pointer moves freely in and out — best for remote desktop work", +]; + +/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page. +const APP_LICENSE: &str = concat!( + "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n", + "================================ MIT ================================\n\n", + include_str!("../../../LICENSE-MIT"), + "\n\n=============================== Apache-2.0 ===============================\n\n", + include_str!("../../../LICENSE-APACHE"), +); +/// Third-party software notices for the linked Rust crates (generated by +/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog). +const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt"); + +/// Show the About dialog (app license + the third-party-software Legal section) — reached +/// from the primary menu (app.rs `win.about`). +pub fn show_about(parent: &impl IsA) { + let about = adw::AboutDialog::builder() + .application_name("Punktfunk") + .developer_name("unom") + .version(env!("CARGO_PKG_VERSION")) + .website("https://git.unom.io/unom/punktfunk") + .license_type(gtk::License::Custom) + .license(APP_LICENSE) + .build(); + // The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own + // (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set. + about.add_legal_section( + "Third-party software (Rust crates)", + None, + gtk::License::Custom, + Some(THIRD_PARTY_NOTICES), + ); + about.add_legal_section( + "Third-party software (system libraries)", + None, + gtk::License::Custom, + Some( + "This application dynamically links system libraries under their own licenses, \ + including FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), \ + and SDL 3 (Zlib). Their full license texts are available from each project.", + ), + ); + about.present(Some(parent)); +} + +/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers +/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown +/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel. +fn gamescope_session() -> bool { + std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope")) + || std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok() +} + +type ChangedFn = Rc>>>; + +/// A titled single-choice preference row. On a desktop this is a stock popover +/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable +/// row that pushes an in-window selection subpage onto the preferences dialog instead. +struct ChoiceRow { + row: adw::PreferencesRow, + selected: Rc>, + /// Fires on user changes only — handlers are installed after seeding, so programmatic + /// `set_selected` during setup never fires them. A list, not one: a row can carry both a + /// dynamic caption and (in profile scope) the override mark. + changed: ChangedFn, + /// Subpage mode only: the current value rendered as the row's suffix. + value_label: Option, + options: Rc>, +} + +impl ChoiceRow { + /// `inline` = subpage mode (gamescope): computed once per dialog via + /// [`gamescope_session`] and passed in so tests can drive both modes directly. + fn new( + dialog: &adw::PreferencesDialog, + inline: bool, + title: &str, + subtitle: &str, + options: &[&str], + ) -> ChoiceRow { + let options: Rc> = Rc::new(options.iter().map(|s| s.to_string()).collect()); + let selected = Rc::new(Cell::new(0u32)); + let changed: ChangedFn = Rc::new(RefCell::new(Vec::new())); + + if !inline { + let row = adw::ComboRow::builder() + .title(title) + .subtitle(subtitle) + .model(>k::StringList::new( + &options.iter().map(String::as_str).collect::>(), + )) + .build(); + let (sel, chg) = (selected.clone(), changed.clone()); + row.connect_selected_notify(move |r| { + if sel.replace(r.selected()) != r.selected() { + for f in chg.borrow().iter() { + f(r.selected()); + } + } + }); + return ChoiceRow { + row: row.upcast(), + selected, + changed, + value_label: None, + options, + }; + } + + let value = gtk::Label::builder().css_classes(["dim-label"]).build(); + let row = adw::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .activatable(true) + .build(); + row.add_suffix(&value); + row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + { + let dialog = dialog.downgrade(); + let (options, sel, chg, value) = ( + options.clone(), + selected.clone(), + changed.clone(), + value.clone(), + ); + let title = title.to_string(); + row.connect_activated(move |_| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + for (i, opt) in options.iter().enumerate() { + let check = gtk::Image::from_icon_name("object-select-symbolic"); + check.set_visible(i as u32 == sel.get()); + let opt_row = adw::ActionRow::builder() + .title(opt) + .use_markup(false) + .activatable(true) + .build(); + opt_row.add_suffix(&check); + let idx = i as u32; + let dlg = dialog.downgrade(); + let (sel, chg, value, label) = + (sel.clone(), chg.clone(), value.clone(), opt.clone()); + opt_row.connect_activated(move |_| { + let user_change = sel.replace(idx) != idx; + value.set_text(&label); + if user_change { + for f in chg.borrow().iter() { + f(idx); + } + } + if let Some(d) = dlg.upgrade() { + d.pop_subpage(); + } + }); + list.append(&opt_row); + } + let clamp = adw::Clamp::builder() + .child(&list) + .margin_top(24) + .margin_bottom(24) + .margin_start(12) + .margin_end(12) + .build(); + let scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&clamp) + .build(); + let view = adw::ToolbarView::new(); + view.add_top_bar(&adw::HeaderBar::new()); + view.set_content(Some(&scroll)); + dialog.push_subpage(&adw::NavigationPage::new(&view, &title)); + }); + } + let cr = ChoiceRow { + row: row.upcast(), + selected, + changed, + value_label: Some(value), + options, + }; + cr.sync_value(); + cr + } + + /// Subpage mode: reflect the current selection in the row's suffix label. + fn sync_value(&self) { + if let Some(l) = &self.value_label { + let i = self.selected.get() as usize; + l.set_text(self.options.get(i).map(String::as_str).unwrap_or("")); + } + } + + fn widget(&self) -> &adw::PreferencesRow { + &self.row + } + + fn selected(&self) -> u32 { + self.selected.get() + } + + fn set_selected(&self, i: u32) { + if let Some(combo) = self.row.downcast_ref::() { + combo.set_selected(i); // the notify handler syncs the cell + } else { + self.selected.set(i); + self.sync_value(); + } + } + + fn connect_changed(&self, f: impl Fn(u32) + 'static) { + self.changed.borrow_mut().push(Box::new(f)); + } +} + +/// Update a row's caption after construction — the dynamic-caption hook (touch mode, +/// resolution, codec). Both ChoiceRow shapes carry their subtitle on [`adw::ActionRow`] +/// ([`adw::ComboRow`] derives from it), so one downcast covers desktop and gamescope mode. +fn set_row_subtitle(row: &adw::PreferencesRow, text: &str) { + if let Some(r) = row.downcast_ref::() { + r.set_subtitle(text); + } +} + +/// The SELECTED resolution choice explained (row index: 0 = Native, 1 = Match window, +/// 2.. = explicit sizes) — one line each, see the caption-width note on +/// [`TOUCH_MODE_CAPTIONS`]. +fn resolution_caption(i: u32) -> &'static str { + match i { + 0 => "The native mode of this monitor, resolved at connect", + 1 => "Follows the stream window — resizes renegotiate the host output", + _ => "The host drives a virtual output at exactly this size", + } +} + +/// The SELECTED codec explained: the PyroWave entry is the one that needs its trade-off +/// spelled out; everything else shares the soft-preference line. +fn codec_caption(i: u32) -> &'static str { + if CODECS.get(i as usize) == Some(&"pyrowave") { + "Wavelet codec for wired LAN — minimal latency, lots of bandwidth" + } else { + "A preference — the host falls back if it can't encode it" + } +} + +/// A settings category page for the dialog's view switcher. +fn page(title: &str, icon: &str) -> adw::PreferencesPage { + adw::PreferencesPage::builder() + // The name addresses the page programmatically (`set_visible_page_name` — the + // screenshot harness's page knob); the title is what the view switcher shows. + .name(title.to_lowercase()) + .title(title) + .icon_name(icon) + .build() +} + +/// Startup device probes for the pickers — filled by the app shell in the background +/// (GPUs via `punktfunk-session --list-adapters`, audio endpoints via the PipeWire +/// registry); any list may still be empty when the dialog opens, which simply hides +/// that picker. +#[derive(Default)] +pub struct DeviceProbes { + pub adapters: Vec, + pub speakers: Vec, + pub mics: Vec, +} + +/// A titled group of rows; `description` (may be empty) is the one form-level note — +/// per-field explanations belong in row subtitles, not here. +fn group(title: &str, description: &str) -> adw::PreferencesGroup { + let g = adw::PreferencesGroup::builder().title(title).build(); + if !description.is_empty() { + g.set_description(Some(description)); + } + g +} + +/// The dialog in a given [`Scope`]. `on_scope` asks the app to re-open it in another one: +/// switching scope closes this dialog first, so the layer being edited is committed before +/// the next one is loaded, and there is exactly one place that builds the rows. +pub fn show_scoped( + parent: &impl IsA, + settings: Rc>, + gamepads: &crate::gamepad::GamepadService, + probes: &DeviceProbes, + scope: Scope, + on_scope: impl Fn(Scope) + 'static, + on_closed: impl Fn() + 'static, +) -> adw::PreferencesDialog { + let catalog = ProfilesFile::load(); + // A scope pointing at a deleted profile degrades to the defaults rather than erroring — + // the same rule a dangling host binding follows. + let active: Option = match &scope { + Scope::Profile(id) => catalog.find_by_id(id).cloned(), + Scope::Defaults => None, + }; + let profile_mode = active.is_some(); + // Rows always show the EFFECTIVE value: the global underneath, with this profile's + // overrides on top. A row the profile doesn't override therefore reads as the live + // global, which is what "inherit by default" has to look like. + let seed: Settings = match &active { + Some(p) => p.overrides.apply(&settings.borrow()), + None => settings.borrow().clone(), + }; + let touched = Touched::default(); + // Fields whose override a per-row reset dropped — applied at commit, after the touched + // ones, so "reset" wins over a value the same row happens to be showing. + let cleared: Rc>> = Rc::default(); + // Where a scope switch wants to go once this dialog has committed and closed. + let next_scope: Rc>> = Rc::default(); + + // The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection + // subpage onto it. + let dialog = adw::PreferencesDialog::new(); + dialog.set_title("Preferences"); + dialog.set_search_enabled(true); + // Wide enough that the category switcher sits in the HEADER BAR (the tabbed look the + // Apple/Windows clients have): AdwPreferencesDialog moves it to a bottom bar below a + // breakpoint of 110pt × page count (≈ 733 px for our five pages). In a window that + // can't give the dialog this width it still collapses to the bottom bar on its own. + dialog.set_content_width(830); + let inline = gamescope_session(); + + // ---- Display: Resolution ---- + // The D1 tri-state: Native, Match window (a virtual index 1, stored as the + // `match_window` flag), then the explicit sizes. + let res_names: Vec = std::iter::once("Native display".to_string()) + .chain(std::iter::once("Match window".to_string())) + .chain( + RESOLUTIONS + .iter() + .skip(1) + .map(|&(w, h)| format!("{w} × {h}")), + ) + .collect(); + let res_row = ChoiceRow::new( + &dialog, + inline, + "Resolution", + resolution_caption(0), + &res_names.iter().map(String::as_str).collect::>(), + ); + { + let w = res_row.widget().clone(); + res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i))); + } + let hz_names: Vec = REFRESH + .iter() + .map(|&r| { + if r == 0 { + "Native".to_string() + } else { + format!("{r} Hz") + } + }) + .collect(); + let hz_row = ChoiceRow::new( + &dialog, + inline, + "Refresh rate", + "Native follows the monitor the window is on", + &hz_names.iter().map(String::as_str).collect::>(), + ); + + // ---- Display: Quality ---- + let scale_names: Vec = RENDER_SCALES + .iter() + .map(|&s| render_scale_label(s)) + .collect(); + let scale_row = ChoiceRow::new( + &dialog, + inline, + "Render scale", + "Above 1× supersamples for sharpness; below is lighter on the host", + &scale_names.iter().map(String::as_str).collect::>(), + ); + let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0); + bitrate_row.set_title("Bitrate"); + bitrate_row + .set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test"); + let codec_row = ChoiceRow::new( + &dialog, + inline, + "Video codec", + codec_caption(0), + CODEC_LABELS, + ); + { + let w = codec_row.widget().clone(); + codec_row.connect_changed(move |i| set_row_subtitle(&w, codec_caption(i))); + } + let hdr_row = adw::SwitchRow::builder() + .title("10-bit HDR") + .subtitle( + "Advertise 10-bit HDR10 so the host upgrades HDR content — shown in HDR where \ + the display supports it, tone-mapped otherwise", + ) + .build(); + let chroma_row = adw::SwitchRow::builder() + .title("Full chroma (4:4:4)") + .subtitle( + "Ask for full-colour video instead of subsampled \u{2014} small text and thin lines \ + stay crisp, at more bandwidth. HEVC only, and only when the host and its GPU can \ + encode it; otherwise the stream stays 4:2:0.", + ) + .build(); + let decoder_row = ChoiceRow::new( + &dialog, + inline, + "Video decoder", + "Automatic picks the best hardware decode, then software", + &["Automatic", "Vulkan Video", "VAAPI", "Software"], + ); + // GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick + // via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to + // pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry. + let saved_adapter = seed.adapter.clone(); + let mut gpu_names = vec!["Automatic".to_string()]; + let mut gpu_keys: Vec = vec![String::new()]; + for a in &probes.adapters { + gpu_names.push(a.clone()); + gpu_keys.push(a.clone()); + } + if !saved_adapter.is_empty() && !gpu_keys.contains(&saved_adapter) { + gpu_names.push(format!("{saved_adapter} (not detected)")); + gpu_keys.push(saved_adapter.clone()); + } + let gpu_row = (gpu_keys.len() > 1).then(|| { + let row = ChoiceRow::new( + &dialog, + inline, + "GPU", + "Decodes and presents the stream", + &gpu_names.iter().map(String::as_str).collect::>(), + ); + let i = gpu_keys + .iter() + .position(|k| k == &saved_adapter) + .unwrap_or(0); + row.set_selected(i as u32); + row + }); + + // ---- Display: Host output ---- + let compositor_row = ChoiceRow::new( + &dialog, + inline, + "Host compositor", + "Advisory — the host falls back to auto-detect when unavailable", + &[ + "Automatic", + "KWin", + "wlroots (Sway/Hyprland)", + "Mutter (GNOME)", + "gamescope", + ], + ); + + // ---- General ---- + let fullscreen_row = adw::SwitchRow::builder() + .title("Start streams in fullscreen") + .subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out") + .build(); + let wake_row = adw::SwitchRow::builder() + .title("Auto-wake on connect") + .subtitle( + "Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \ + off if hosts behind a VPN look offline when they aren't", + ) + .build(); + let stats_row = ChoiceRow::new( + &dialog, + inline, + "Statistics overlay", + "Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live", + &["Off", "Compact", "Normal", "Detailed"], + ); + let library_row = adw::SwitchRow::builder() + .title("Show game library") + .subtitle( + "Adds “Browse library…” to paired hosts — list their Steam and custom games \ + and launch one directly. No extra host setup", + ) + .build(); + + // ---- Input ---- + let touch_row = ChoiceRow::new( + &dialog, + inline, + "Touch input", + TOUCH_MODE_CAPTIONS[0], + TOUCH_MODE_LABELS, + ); + // Dynamic caption: describe the SELECTED mode, not all three at once. + { + let w = touch_row.widget().clone(); + touch_row.connect_changed(move |i| { + let i = (i as usize).min(TOUCH_MODE_CAPTIONS.len() - 1); + set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]); + }); + } + let mouse_row = ChoiceRow::new( + &dialog, + inline, + "Mouse input", + MOUSE_MODE_CAPTIONS[0], + MOUSE_MODE_LABELS, + ); + { + let w = mouse_row.widget().clone(); + mouse_row.connect_changed(move |i| { + let i = (i as usize).min(MOUSE_MODE_CAPTIONS.len() - 1); + set_row_subtitle(&w, MOUSE_MODE_CAPTIONS[i]); + }); + } + let inhibit_row = adw::SwitchRow::builder() + .title("Capture system shortcuts") + .subtitle("Forward Alt+Tab, Super, … to the host while input is captured") + .build(); + let invert_row = adw::SwitchRow::builder() + .title("Invert scroll direction") + .subtitle("Reverses the wheel and trackpad scroll direction sent to the host") + .build(); + + // ---- Audio ---- + let surround_row = ChoiceRow::new( + &dialog, + inline, + "Audio channels", + "Stereo or surround — the host downmixes if its output has fewer", + &["Stereo", "5.1 Surround", "7.1 Surround"], + ); + let mic_row = adw::SwitchRow::builder() + .title("Stream microphone") + .subtitle("Sends your microphone to the host's virtual mic") + .build(); + // Endpoint pickers (from the PipeWire probe): visible labels are descriptions, the + // stored value is the node name. Hidden when the probe found nothing; a saved + // device that's gone keeps a revertable "(not detected)" entry, like the GPU row. + let dev_row = |saved: String, + devs: &[pf_client_core::audio::AudioDevice], + title: &str, + subtitle: &str| { + let mut names = vec!["System default".to_string()]; + let mut keys = vec![String::new()]; + for d in devs { + names.push(d.description.clone()); + keys.push(d.name.clone()); + } + if !saved.is_empty() && !keys.contains(&saved) { + names.push(format!("{saved} (not detected)")); + keys.push(saved.clone()); + } + let row = (keys.len() > 1).then(|| { + let row = ChoiceRow::new( + &dialog, + inline, + title, + subtitle, + &names.iter().map(String::as_str).collect::>(), + ); + row.set_selected(keys.iter().position(|k| k == &saved).unwrap_or(0) as u32); + row + }); + (row, keys) + }; + let (speaker_row, speaker_keys) = dev_row( + settings.borrow().speaker_device.clone(), + &probes.speakers, + "Speaker", + "Host audio plays here — System default follows the desktop", + ); + let (micdev_row, micdev_keys) = dev_row( + settings.borrow().mic_device.clone(), + &probes.mics, + "Microphone", + "The input that feeds the host's virtual mic", + ); + // The device pick only matters while the mic streams at all. + if let Some(r) = &micdev_row { + let w = r.widget().clone(); + w.set_sensitive(mic_row.is_active()); + mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active())); + } + + // ---- Controllers ---- + // Controller forwarding: Automatic forwards EVERY real controller, each as its own pad + // (Steam's virtual pad skipped); pinning one restricts the session to that single + // controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`), + // so it survives restarts — and disconnects: an offline pinned pad keeps its entry here + // instead of silently snapping back to Automatic. + let pads = gamepads.pads(); + let saved_pin = settings.borrow().forward_pad.clone(); + let mut pad_names = vec!["Automatic (all controllers)".to_string()]; + let mut pad_keys: Vec = Vec::new(); + for p in &pads { + let kind = p.kind_label(); + pad_names.push(if kind.is_empty() { + p.name.clone() + } else { + format!("{} · {kind}", p.name) + }); + pad_keys.push(p.key.clone()); + } + if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) { + let name = saved_pin + .splitn(3, ':') + .nth(2) + .unwrap_or("Saved controller"); + pad_names.push(format!("{name} (not connected)")); + pad_keys.push(saved_pin.clone()); + } + let forward_row = ChoiceRow::new( + &dialog, + inline, + "Forwarded controller", + if pads.is_empty() { + "No controllers detected" + } else { + "Every pad is its own player — pick one to force single-player" + }, + &pad_names.iter().map(String::as_str).collect::>(), + ); + let pinned_i = pad_keys + .iter() + .position(|k| k == &saved_pin) + .map_or(0, |i| i + 1); + forward_row.set_selected(pinned_i as u32); + // The dialog-local choice, written into Settings on close (reading the service back + // would race its worker thread applying the Pin message). + let chosen_pin: Rc> = Rc::new(RefCell::new(saved_pin)); + { + let svc = gamepads.clone(); + let keys = pad_keys.clone(); + let chosen = chosen_pin.clone(); + forward_row.connect_changed(move |sel| { + let key = if sel == 0 { + None + } else { + keys.get(sel as usize - 1).cloned() + }; + *chosen.borrow_mut() = key.clone().unwrap_or_default(); + svc.set_pinned(key); + }); + } + let pad_row = ChoiceRow::new( + &dialog, + inline, + "Gamepad type", + "The virtual pad on the host — Automatic matches your controller", + &[ + "Automatic", + "Xbox 360", + "DualSense", + "Xbox One", + "DualShock 4", + "Steam Deck", + ], + ); + + // ---- Seed from the effective settings for this scope ---- + { + let s = &seed; + let res_i = if s.match_window { + 1 + } else { + RESOLUTIONS + .iter() + .position(|&(w, h)| w == s.width && h == s.height) + .map(|i| if i == 0 { 0 } else { i + 1 }) + .unwrap_or(0) + }; + res_row.set_selected(res_i as u32); + set_row_subtitle(res_row.widget(), resolution_caption(res_i as u32)); + let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0); + hz_row.set_selected(hz_i as u32); + let scale_i = RENDER_SCALES + .iter() + .position(|&x| (x - s.render_scale).abs() < 1e-6) + .unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap()); + scale_row.set_selected(scale_i as u32); + bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); + let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0); + pad_row.set_selected(pad_i as u32); + let touch_i = TOUCH_MODES + .iter() + .position(|&t| t == s.touch_mode) + .unwrap_or(0); + touch_row.set_selected(touch_i as u32); + // set_selected never fires the changed hook, so seed the dynamic caption directly. + set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]); + let mouse_i = MOUSE_MODES + .iter() + .position(|&m| m == s.mouse_mode) + .unwrap_or(0); + mouse_row.set_selected(mouse_i as u32); + set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i]); + let comp_i = COMPOSITORS + .iter() + .position(|&c| c == s.compositor) + .unwrap_or(0); + compositor_row.set_selected(comp_i as u32); + let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); + decoder_row.set_selected(dec_i as u32); + let stats_i = StatsVerbosity::ALL + .iter() + .position(|v| *v == s.stats_verbosity()) + .unwrap_or(0); + stats_row.set_selected(stats_i as u32); + fullscreen_row.set_active(s.fullscreen_on_stream); + wake_row.set_active(s.auto_wake); + inhibit_row.set_active(s.inhibit_shortcuts); + invert_row.set_active(s.invert_scroll); + mic_row.set_active(s.mic_enabled); + hdr_row.set_active(s.hdr_enabled); + chroma_row.set_active(s.enable_444); + library_row.set_active(s.library_enabled); + surround_row.set_selected(match s.audio_channels { + 6 => 1, + 8 => 2, + _ => 0, + }); + let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0); + codec_row.set_selected(codec_i as u32); + set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32)); + } + + // ---- Override markers, per-row reset, and the touch that creates an override ---- + // One pass per row, because the three are the same fact seen from different sides: the + // marker says "this profile changes this", the reset is the only way back (the override + // model never infers "not overridden" from a value comparison), and touching the control + // is what creates it. The marker therefore has to appear ON TOUCH, not on the next open — + // a user who changes a row and sees nothing has no reason to believe it took. + // + // Wired after the seed block on purpose: `set_selected`/`set_active` during setup must not + // look like the user touching anything, or opening a profile would override every row. + if let Some(active) = &active { + let o = &active.overrides; + // Build the marker for one row and hand back a "now it's overridden" switch its + // control's change handler can pull. + let mark = |row: &adw::PreferencesRow, + key: &'static str, + overridden: bool| + -> Option> { + let row = row.downcast_ref::()?; + let dot = gtk::Box::builder() + .css_classes(["pf-override-dot"]) + .valign(gtk::Align::Center) + .visible(overridden) + .build(); + row.add_prefix(&dot); + let reset = gtk::Button::builder() + .icon_name("edit-undo-symbolic") + .tooltip_text("Reset to Default settings") + .valign(gtk::Align::Center) + .visible(overridden) + .css_classes(["flat"]) + .build(); + { + let (dialog, next, cleared, scope) = ( + dialog.clone(), + next_scope.clone(), + cleared.clone(), + scope.clone(), + ); + let touched = touched.clone(); + reset.connect_clicked(move |_| { + // Queue the clear and re-open in the same scope: the close handler commits + // whatever else was edited first, then drops this field, and the rebuilt + // rows show the inherited value. Dropping it from `touched` too keeps a + // reset-after-touch from re-writing what the reset just removed. + touched.forget(key); + cleared.borrow_mut().insert(key); + *next.borrow_mut() = Some(scope.clone()); + dialog.close(); + }); + } + row.add_suffix(&reset); + Some(Box::new(move || { + dot.set_visible(true); + reset.set_visible(true); + })) + }; + + // `(row widget, overlay field, is it overridden right now)` — then the control's own + // change signal marks it touched AND lights the marker. + macro_rules! choice { + ($row:expr, $key:literal, $overridden:expr) => {{ + let show = mark($row.widget(), $key, $overridden); + let t = touched.clone(); + $row.connect_changed(move |_| { + t.mark($key); + if let Some(show) = &show { + show(); + } + }); + }}; + } + macro_rules! toggle { + ($row:expr, $key:literal, $overridden:expr) => {{ + let show = mark($row.upcast_ref(), $key, $overridden); + let t = touched.clone(); + $row.connect_active_notify(move |_| { + t.mark($key); + if let Some(show) = &show { + show(); + } + }); + }}; + } + + choice!( + res_row, + "resolution", + o.width.is_some() || o.height.is_some() || o.match_window.is_some() + ); + choice!(hz_row, "refresh_hz", o.refresh_hz.is_some()); + choice!(scale_row, "render_scale", o.render_scale.is_some()); + choice!(codec_row, "codec", o.codec.is_some()); + choice!(compositor_row, "compositor", o.compositor.is_some()); + choice!(stats_row, "stats_verbosity", o.stats_verbosity.is_some()); + choice!(touch_row, "touch_mode", o.touch_mode.is_some()); + choice!(mouse_row, "mouse_mode", o.mouse_mode.is_some()); + choice!(surround_row, "audio_channels", o.audio_channels.is_some()); + choice!(pad_row, "gamepad", o.gamepad.is_some()); + toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some()); + toggle!(chroma_row, "enable_444", o.enable_444.is_some()); + toggle!( + fullscreen_row, + "fullscreen_on_stream", + o.fullscreen_on_stream.is_some() + ); + toggle!( + inhibit_row, + "inhibit_shortcuts", + o.inhibit_shortcuts.is_some() + ); + toggle!(invert_row, "invert_scroll", o.invert_scroll.is_some()); + toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some()); + { + let show = mark( + bitrate_row.upcast_ref(), + "bitrate_kbps", + o.bitrate_kbps.is_some(), + ); + let t = touched.clone(); + bitrate_row.connect_value_notify(move |_| { + t.mark("bitrate_kbps"); + if let Some(show) = &show { + show(); + } + }); + } + } + + // ---- Assemble the category pages (the Apple revamp's map) ---- + let general = page("General", "preferences-system-symbolic"); + // The scope switcher heads the first page — the one row that is always about which layer + // you are editing, not about the stream. + general.add(&scope_group( + &dialog, + inline, + &scope, + &catalog, + active.as_ref(), + &next_scope, + parent, + )); + let session_group = group("Session", ""); + session_group.add(&fullscreen_row); + // Auto-wake is a property of the host and this network, not of "Game vs Work" — it stays + // global in v1 (design §3, tier H/G). + if !profile_mode { + session_group.add(&wake_row); + } + let stats_group = group("Statistics", ""); + stats_group.add(stats_row.widget()); + general.add(&session_group); + general.add(&stats_group); + // The library browser is an app-level toggle for this device, not a per-profile one. + if !profile_mode { + let library_group = group("Library", ""); + library_group.add(&library_row); + general.add(&library_group); + } + + let display = page("Display", "video-display-symbolic"); + let resolution_group = group("Resolution", ""); + resolution_group.add(res_row.widget()); + resolution_group.add(hz_row.widget()); + let quality_group = group("Quality", ""); + quality_group.add(scale_row.widget()); + quality_group.add(&bitrate_row); + quality_group.add(codec_row.widget()); + quality_group.add(&hdr_row); + quality_group.add(&chroma_row); + // Decoder and GPU are facts about THIS device's hardware — never per profile (tier G). + if !profile_mode { + quality_group.add(decoder_row.widget()); + } + if let (Some(r), false) = (&gpu_row, profile_mode) { + quality_group.add(r.widget()); + } + // The one form-level note (deliberately not repeated on every row). + let output_group = group( + "Host output", + "Display changes apply from the next session.", + ); + output_group.add(compositor_row.widget()); + display.add(&resolution_group); + display.add(&quality_group); + display.add(&output_group); + + let input = page("Input", "input-keyboard-symbolic"); + let touch_group = group("Touch", ""); + touch_group.add(touch_row.widget()); + // Group titles are Pango markup — the ampersand must be an entity. + let kbm_group = group("Keyboard & mouse", ""); + kbm_group.add(mouse_row.widget()); + kbm_group.add(&inhibit_row); + kbm_group.add(&invert_row); + input.add(&touch_group); + input.add(&kbm_group); + + let audio = page("Audio", "audio-volume-high-symbolic"); + let audio_group = group("", "Applies from the next session."); + audio_group.add(surround_row.widget()); + // The speaker/mic endpoint pickers below are this device's audio routing (tier G) — they + // render only in the defaults scope; the surround + mic-uplink rows above are profileable. + + if let (Some(r), false) = (&speaker_row, profile_mode) { + audio_group.add(r.widget()); + } + audio_group.add(&mic_row); + if let (Some(r), false) = (&micdev_row, profile_mode) { + audio_group.add(r.widget()); + } + audio.add(&audio_group); + + let controllers = page("Controllers", "input-gaming-symbolic"); + let controllers_group = group("", ""); + // The detected-pad list (mirrors the Apple Controllers section): informational rows + // above the pickers, from the same snapshot that feeds the forwarding picker. It is + // about the hardware plugged into THIS device, so profile scope shows only the + // emulated-type picker below it. + if profile_mode { + // nothing — the pad inventory belongs to the device, not the profile + } else if pads.is_empty() { + let none = adw::ActionRow::builder() + .title("No controllers detected") + .css_classes(["dim-label"]) + .build(); + controllers_group.add(&none); + } else { + for p in &pads { + let row = adw::ActionRow::builder() + .title(&p.name) + .use_markup(false) + .build(); + if p.steam_virtual { + row.set_subtitle( + "Steam Input's virtual pad — Automatic skips it while a real pad is connected", + ); + } else { + row.set_subtitle(p.kind_label()); + } + row.add_prefix(>k::Image::from_icon_name("input-gaming-symbolic")); + controllers_group.add(&row); + } + } + if !profile_mode { + controllers_group.add(forward_row.widget()); + } + controllers_group.add(pad_row.widget()); + controllers.add(&controllers_group); + + dialog.add(&general); + dialog.add(&display); + dialog.add(&input); + dialog.add(&audio); + dialog.add(&controllers); + + dialog.connect_closed(move |_| { + // One reader for the rows, two destinations: the globals, or a profile's overrides. + // Sharing it is what keeps the two scopes from interpreting the same controls + // differently (the tri-state resolution row is the obvious trap). + let apply_rows = |s: &mut Settings| { + // Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit. + let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len()); + s.match_window = res_i == 1; + (s.width, s.height) = if res_i <= 1 { + (0, 0) + } else { + RESOLUTIONS[res_i - 1] + }; + s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; + s.render_scale = + RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)]; + s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; + // Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the + // session, hand-edited or written by another client): it displays as "Automatic", and + // writing that back would silently erase it just by opening + closing the dialog. + // Persist the row only when the user picked a non-Auto entry or the stored value was + // a listed one to begin with. + let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1); + if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) { + s.gamepad = GAMEPADS[pad_sel].to_string(); + } + s.touch_mode = + TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string(); + s.mouse_mode = + MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string(); + s.forward_pad = chosen_pin.borrow().clone(); + s.compositor = COMPOSITORS + [(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] + .to_string(); + s.decoder = + DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); + if let Some(r) = &gpu_row { + s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone(); + } + if let Some(r) = &speaker_row { + s.speaker_device = + speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone(); + } + if let Some(r) = &micdev_row { + s.mic_device = + micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone(); + } + s.set_stats_verbosity( + StatsVerbosity::ALL + [(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)], + ); + s.fullscreen_on_stream = fullscreen_row.is_active(); + s.auto_wake = wake_row.is_active(); + s.inhibit_shortcuts = inhibit_row.is_active(); + s.invert_scroll = invert_row.is_active(); + s.mic_enabled = mic_row.is_active(); + s.hdr_enabled = hdr_row.is_active(); + s.enable_444 = chroma_row.is_active(); + s.audio_channels = match surround_row.selected() { + 1 => 6, + 2 => 8, + _ => 2, + }; + s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string(); + s.library_enabled = library_row.is_active(); + }; + + match &active { + // Profile scope writes the touched rows into the catalog and leaves the globals + // exactly as they were — the point of the whole feature. + Some(active) => { + let mut values = seed.clone(); + apply_rows(&mut values); + commit_profile(active, &touched, &cleared.borrow(), &values); + } + None => { + let mut s = settings.borrow_mut(); + apply_rows(&mut s); + s.save(); + } + } + // A scope switch closed this dialog to commit first; now re-open in the new scope. + if let Some(next) = next_scope.borrow_mut().take() { + on_scope(next); + } + on_closed(); + }); + dialog.present(Some(parent)); + dialog +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Depth-first search for an [`adw::ActionRow`] with the given title. + fn find_action_row(root: >k::Widget, title: &str) -> Option { + if let Some(row) = root.downcast_ref::() { + if row.title() == title { + return Some(row.clone()); + } + } + let mut child = root.first_child(); + while let Some(c) = child { + if let Some(hit) = find_action_row(&c, title) { + return Some(hit); + } + child = c.next_sibling(); + } + None + } + + fn pump() { + let ctx = gtk::glib::MainContext::default(); + while ctx.iteration(false) {} + } + + /// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test + /// its own thread, so the display tests can't be split). Gamescope mode: activating the + /// row pushes the in-window selection subpage; activating an option updates the + /// selection + suffix label, fires the change callback, and pops the subpage. Combo + /// mode: cell sync + change callback. Needs a display — run manually with + /// `cargo test -p punktfunk-client-linux -- --ignored` on a session box. + #[test] + #[ignore = "needs a Wayland/X display"] + fn choice_row_modes() { + assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display"); + let win = adw::Window::new(); + let dialog = adw::PreferencesDialog::new(); + let page = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::new(); + let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]); + group.add(row.widget()); + page.add(&group); + dialog.add(&page); + let fired = Rc::new(Cell::new(u32::MAX)); + { + let f = fired.clone(); + row.connect_changed(move |i| f.set(i)); + } + win.present(); + dialog.present(Some(&win)); + pump(); + + // Suffix label reflects the seed. + assert_eq!(row.value_label.as_ref().unwrap().text(), "A"); + + // Row activation → subpage with the options list. + row.widget() + .downcast_ref::() + .unwrap() + .emit_by_name::<()>("activated", &[]); + pump(); + let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing"); + + // Option activation → state + label + callback, subpage popped. + opt_b.emit_by_name::<()>("activated", &[]); + pump(); + assert_eq!(row.selected(), 1); + assert_eq!(fired.get(), 1); + assert_eq!(row.value_label.as_ref().unwrap().text(), "B"); + + // The dynamic-caption hook drives the same subtitle both row shapes expose. + set_row_subtitle(row.widget(), "swapped"); + assert_eq!( + row.widget() + .downcast_ref::() + .unwrap() + .subtitle() + .as_deref(), + Some("swapped") + ); + + // Re-activating shows the check on the new selection (fresh subpage each time). + row.widget() + .downcast_ref::() + .unwrap() + .emit_by_name::<()>("activated", &[]); + pump(); + assert!(find_action_row(dialog.upcast_ref(), "B").is_some()); + + // Desktop (ComboRow) mode: cell sync + change callback on selection change. + let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]); + combo.set_selected(1); + assert_eq!(combo.selected(), 1); + let combo_fired = Rc::new(Cell::new(u32::MAX)); + { + let f = combo_fired.clone(); + combo.connect_changed(move |i| f.set(i)); + } + combo.set_selected(0); + assert_eq!(combo.selected(), 0); + assert_eq!(combo_fired.get(), 0); + // ComboRow derives from ActionRow, so the caption hook reaches it too. + set_row_subtitle(combo.widget(), "combo caption"); + assert_eq!( + combo + .widget() + .downcast_ref::() + .unwrap() + .subtitle() + .as_deref(), + Some("combo caption") + ); + } +} diff --git a/clients/session/src/ui_trust.rs b/clients/session/src/ui_trust.rs new file mode 100644 index 00000000..f078462f --- /dev/null +++ b/clients/session/src/ui_trust.rs @@ -0,0 +1,350 @@ +//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and +//! delegated (request-access) approval. The trust GATE itself (rules 1–3) lives in +//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens, +//! each resolving into typed [`AppMsg`]s. + +use crate::app::{AppModel, AppMsg}; +use crate::spawn::{CancelHandle, SpawnOpts}; +use crate::trust; +use crate::ui_hosts::ConnectRequest; +use adw::prelude::*; +use gtk::glib; +use pf_client_core::orchestrate::{WakeOutcome, WakeWait}; +use relm4::prelude::*; + +/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a +/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is +/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few +/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved +/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog +/// lets the user cancel. +/// +/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported +/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the +/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate. +pub fn wake_and_connect( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + req: ConnectRequest, +) { + let cancel = std::rc::Rc::new(std::cell::Cell::new(false)); + let waiting = adw::AlertDialog::new( + Some("Waking Host"), + Some(&format!( + "Sent a wake signal to “{}”. Waiting for it to come online…", + req.name + )), + ); + waiting.add_responses(&[("cancel", "Cancel")]); + waiting.set_close_response("cancel"); + { + let cancel = cancel.clone(); + waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true)); + } + waiting.present(Some(window)); + + let sender = sender.clone(); + glib::spawn_future_local(async move { + use std::time::Duration; + let events = crate::discovery::browse(); + let mut wait = WakeWait::new(); + loop { + if cancel.get() { + waiting.close(); + return; + } + // Drain resolved adverts; a match (fingerprint, else addr:port) means it is up, + // and carries the address it came back on. + let mut seen: Option<(String, u16)> = None; + while let Ok(ev) = events.try_recv() { + let crate::discovery::DiscoveryEvent::Resolved(h) = ev else { + continue; + }; + let matched = match &req.fp_hex { + Some(fp) => !h.fp_hex.is_empty() && &h.fp_hex == fp, + None => h.addr == req.addr && h.port == req.port, + }; + if matched { + seen = Some((h.addr, h.port)); + } + } + let tick = wait.tick(seen.is_some()); + if tick.send_packet { + crate::wol::wake(&req.mac, req.addr.parse().ok()); + } + match tick.outcome { + Some(WakeOutcome::Online) => { + waiting.close(); + let mut req = req.clone(); + // Re-key on a new DHCP lease so this + future connects dial the + // live address. + if let Some((addr, port)) = + seen.filter(|(a, p)| *a != req.addr || *p != req.port) + { + if let Some(fp) = &req.fp_hex { + trust::rekey_addr(fp, &addr, port); + } + req.addr = addr; + req.port = port; + } + sender.input(AppMsg::Connect(req)); + return; + } + Some(WakeOutcome::TimedOut) => { + waiting.close(); + sender.input(AppMsg::Toast(format!( + "Couldn't reach “{}” — is it powered and on the network?", + req.name + ))); + return; + } + None => {} + } + glib::timeout_future(Duration::from_secs(1)).await; + } + }); +} + +/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines, +/// far easier to compare against the host's log than one 64-char run. +fn grouped_fingerprint(fp: &str) -> String { + let groups: Vec<&str> = fp + .as_bytes() + .chunks(4) + .map(|c| std::str::from_utf8(c).unwrap_or("")) + .collect(); + groups + .chunks(8) + .map(|line| line.join(" ")) + .collect::>() + .join("\n") +} + +/// First contact with a discovered host that opted into TOFU: show the advertised +/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away. +/// Trusting starts a session pinned to the advertised fp; it persists once the child +/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`). +pub fn tofu_dialog( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + req: ConnectRequest, +) { + let fp = req.fp_hex.clone().unwrap_or_default(); + let dialog = adw::AlertDialog::new( + Some("New Host"), + Some(&format!( + "{} at {}:{}\n\nPairing with a PIN verifies the certificate fingerprint below; \ + trusting accepts it as-is.", + req.name, req.addr, req.port + )), + ); + let fp_label = gtk::Label::new(Some(&grouped_fingerprint(&fp))); + fp_label.add_css_class("monospace"); + fp_label.set_selectable(true); + fp_label.set_justify(gtk::Justification::Center); + fp_label.set_halign(gtk::Align::Center); + dialog.set_extra_child(Some(&fp_label)); + dialog.add_responses(&[ + ("cancel", "Cancel"), + ("pair", "Pair with PIN…"), + ("trust", "Trust & Connect"), + ]); + dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("trust")); + dialog.set_close_response("cancel"); + let sender = sender.clone(); + dialog.connect_response(None, move |_, response| match response { + "trust" => sender.input(AppMsg::StartSession { + req: req.clone(), + fp_hex: fp.clone(), + tofu: true, + opts: SpawnOpts::default(), + }), + "pair" => sender.input(AppMsg::Pair(req.clone())), + _ => {} + }); + dialog.present(Some(window)); +} + +/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge +/// of it pins the host's certificate (and registers ours) with no offline-guessable +/// transcript. Success persists the host as paired and connects. +pub fn pin_dialog( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + identity: (String, String), + req: ConnectRequest, +) { + let entry = gtk::Entry::builder() + .input_purpose(gtk::InputPurpose::Digits) + .placeholder_text("4-digit PIN shown by the host") + .activates_default(true) + .build(); + // The label the HOST stores this client under — prefilled with the hostname. + let name_entry = gtk::Entry::builder() + .text(glib::host_name().as_str()) + .activates_default(true) + .build(); + let name_caption = gtk::Label::new(Some("This device")); + name_caption.add_css_class("caption"); + name_caption.add_css_class("dim-label"); + name_caption.set_halign(gtk::Align::Start); + let fields = gtk::Box::new(gtk::Orientation::Vertical, 6); + fields.append(&name_caption); + fields.append(&name_entry); + let pin_caption = gtk::Label::new(Some("PIN")); + pin_caption.add_css_class("caption"); + pin_caption.add_css_class("dim-label"); + pin_caption.set_halign(gtk::Align::Start); + fields.append(&pin_caption); + fields.append(&entry); + let dialog = adw::AlertDialog::new( + Some("Pair with PIN"), + Some(&format!( + "Arm pairing on {} (console or web UI), then enter the PIN it displays.", + req.name + )), + ); + dialog.set_extra_child(Some(&fields)); + dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]); + dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("pair")); + dialog.set_close_response("cancel"); + let sender = sender.clone(); + dialog.connect_response(Some("pair"), move |_, _| { + let pin = entry.text().to_string(); + let req = req.clone(); + let identity = identity.clone(); + let sender = sender.clone(); + let (tx, rx) = async_channel::bounded::>(1); + let device = name_entry.text().trim().to_string(); + let name = if device.is_empty() { + glib::host_name().to_string() + } else { + device + }; + let (host, port) = (req.addr.clone(), req.port); + std::thread::spawn(move || { + // Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host + // rejection) — never blame the PIN for a dead network path. + let result = trust::pair_with_host(&host, port, &identity, &pin, &name) + .map_err(|e| trust::pair_error_message(&e)); + let _ = tx.send_blocking(result); + }); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(fp)) => { + let fp_hex = trust::hex(&fp); + trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true); + sender.input(AppMsg::Toast("Paired — connecting…".into())); + sender.input(AppMsg::StartSession { + req, + fp_hex, + tofu: false, + opts: SpawnOpts::default(), + }); + } + Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)), + Err(_) => {} + } + }); + }); + dialog.present(Some(window)); +} + +/// A fresh host that requires pairing: "Request access" (connect and wait for the +/// operator to click Approve in the host's console — delegated approval) or the PIN +/// ceremony. +pub fn approval_dialog( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + waiting_slot: std::rc::Rc>>, + req: ConnectRequest, +) { + let dialog = adw::AlertDialog::new( + Some("Pairing Required"), + Some(&format!( + "{} requires pairing.\n\nRequest access and approve this device in the host's console \ + (or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.", + req.name + )), + ); + dialog.add_responses(&[ + ("cancel", "Cancel"), + ("pin", "Use a PIN instead…"), + ("request", "Request Access"), + ]); + dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("request")); + dialog.set_close_response("cancel"); + let parent = window.clone(); + let window = window.clone(); + let sender = sender.clone(); + dialog.connect_response(None, move |_, response| match response { + "request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()), + "pin" => sender.input(AppMsg::Pair(req.clone())), + _ => {} + }); + dialog.present(Some(&parent)); +} + +/// The no-PIN "request access" flow: the session child opens an identified connect the +/// host PARKS until the operator approves it in the console; a cancelable "waiting" +/// dialog covers the wait. On approval the same connection is admitted and the host is +/// saved as paired. Cancel kills the child (the only abort a parked connect has). +/// +/// The pinned fingerprint is the advertised one for a discovered host (defence against +/// an impostor while we wait). A manually-typed host has no advertised fingerprint — +/// the session binary refuses pinless connects, so this path requires the advert; a +/// manual entry's Request Access rides the same flow only when a fingerprint exists. +fn request_access( + window: &adw::ApplicationWindow, + sender: &ComponentSender, + waiting_slot: std::rc::Rc>>, + req: ConnectRequest, +) { + let Some(fp_hex) = req.fp_hex.clone() else { + // No fingerprint to pin (manual entry): the strict child can't do a + // trust-on-approval connect — route to the PIN ceremony instead. + sender.input(AppMsg::Toast( + "No advertised identity for this host — pair with a PIN instead.".into(), + )); + sender.input(AppMsg::Pair(req)); + return; + }; + let cancel = CancelHandle::default(); + let waiting = adw::AlertDialog::new( + Some("Waiting for Approval"), + Some(&format!( + "Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \ + connects automatically once you approve it.", + glib::host_name(), + req.name + )), + ); + waiting.add_responses(&[("cancel", "Cancel")]); + waiting.set_close_response("cancel"); + { + let sender = sender.clone(); + let cancel = cancel.clone(); + waiting.connect_response(Some("cancel"), move |_, _| { + cancel.kill(); + sender.input(AppMsg::CancelPending); + }); + } + waiting.present(Some(window)); + *waiting_slot.borrow_mut() = Some(waiting); + + sender.input(AppMsg::StartSession { + req, + fp_hex, + tofu: false, + opts: SpawnOpts { + // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow + // operator approval still lands on this connection. + connect_timeout_secs: Some(185), + persist_paired: true, + cancel: Some(cancel), + }, + }); +} diff --git a/crates/pf-client-core/src/profiles.rs b/crates/pf-client-core/src/profiles.rs index 0a30bf22..608ebf1e 100644 --- a/crates/pf-client-core/src/profiles.rs +++ b/crates/pf-client-core/src/profiles.rs @@ -54,6 +54,8 @@ pub struct SettingsOverlay { #[serde(skip_serializing_if = "Option::is_none")] pub hdr_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub enable_444: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub compositor: Option, #[serde(skip_serializing_if = "Option::is_none")] pub audio_channels: Option, @@ -108,6 +110,9 @@ impl SettingsOverlay { if let Some(v) = self.hdr_enabled { s.hdr_enabled = v; } + if let Some(v) = self.enable_444 { + s.enable_444 = v; + } if let Some(v) = &self.compositor { s.compositor = v.clone(); } @@ -180,6 +185,9 @@ impl SettingsOverlay { if after.hdr_enabled != before.hdr_enabled { self.hdr_enabled = Some(after.hdr_enabled); } + if after.enable_444 != before.enable_444 { + self.enable_444 = Some(after.enable_444); + } if after.compositor != before.compositor { self.compositor = Some(after.compositor.clone()); } @@ -231,6 +239,7 @@ impl SettingsOverlay { "render_scale" => self.render_scale = None, "codec" => self.codec = None, "hdr_enabled" => self.hdr_enabled = None, + "enable_444" => self.enable_444 = None, "compositor" => self.compositor = None, "audio_channels" => self.audio_channels = None, "mic_enabled" => self.mic_enabled = None, diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 7f5dfca5..330b256a 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -719,6 +719,13 @@ pub struct Settings { /// stores (and the Linux shells, which have no picker yet) load. #[serde(default)] pub adapter: String, + /// Ask the host for full-chroma **4:4:4** video (`quic::VIDEO_CAP_444`). Default off: it + /// costs bandwidth and encode headroom, and only lands when everything lines up — HEVC, + /// the host's own policy, and a GPU that can actually encode 4:4:4. It is what makes small + /// text and thin UI lines crisp on a remote desktop, which is why this is a per-profile + /// choice rather than a global one (a "Work" profile wants it; "Game" usually doesn't). + #[serde(default)] + pub enable_444: bool, /// Advertise 10-bit + HDR10 so the host upgrades HDR content to a Main10/PQ stream. /// The presenter handles the display side dynamically either way (HDR10 swapchain /// where offered, tonemap where not) — off means "never send me 10-bit". @@ -854,6 +861,7 @@ impl Default for Settings { codec: "auto".into(), decoder: "auto".into(), adapter: String::new(), + enable_444: false, hdr_enabled: true, show_stats: true, stats_verbosity: None,