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,