fix(client/linux): the override marker appears on touch, profiles get a colour, and 4:4:4 gets a switch
Three things found by actually driving the client. **The marker didn't appear until you reopened the dialog.** It was rendered once, at build time, from the stored overlay — so changing a setting inside a profile looked like it did nothing. The design says touching a control creates the override and the marker appears immediately, and it has to: a user who changes a row and sees no acknowledgement has no reason to believe it took. Every profileable row now builds its dot and reset hidden, and the same handler that records the touch reveals them. Resetting a row touched in the same sitting also un-touches it, so the commit can't re-write what the reset just removed. **Profiles had no colour.** `accent` has been in the schema since P0 and nothing could set it, which left every chip the same grey — and telling profiles apart at a glance across a grid is the entire reason chips exist. Creating a profile now picks a colour in the same breath as its name (hunting for it afterwards is what leaves them all grey), an existing profile has a Colour row, and host-card chips are tinted with it. A palette of eight rather than a free picker: legibility across light and dark is the job, and the schema still accepts any `#RRGGBB` a hand-edit or a future picker writes. Anything that isn't `#RRGGBB` is refused rather than interpolated into CSS, and each distinct colour registers one display-wide rule (per-widget providers are gone since GTK 4.10). **4:4:4 had no switch anywhere but Apple.** `VIDEO_CAP_444` has been on the wire for a while with only `punktfunk-probe`'s env var to set it. It is now a setting — and a profileable one, which is the point: full chroma is what makes small text and thin UI lines crisp, so a "Work" profile wants it where "Game" usually doesn't. The host still gates it on its own policy, HEVC, and a GPU that can actually encode it; advertising only says "I can decode this and I want it". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,19 @@ const CSS: &str = "
|
|||||||
-- a quote in here would end it.) */
|
-- a quote in here would end it.) */
|
||||||
.pf-override-dot { min-width: 8px; min-height: 8px; border-radius: 999px;
|
.pf-override-dot { min-width: 8px; min-height: 8px; border-radius: 999px;
|
||||||
background: @accent_color; }
|
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
|
/* 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
|
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
|
||||||
the card's own elevation shadow intact. */
|
the card's own elevation shadow intact. */
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ pub struct HostCard {
|
|||||||
connecting: bool,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum CardKind {
|
enum CardKind {
|
||||||
Saved {
|
Saved {
|
||||||
@@ -69,7 +78,7 @@ enum CardKind {
|
|||||||
library_enabled: bool,
|
library_enabled: bool,
|
||||||
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
|
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
|
||||||
/// refresh rather than re-read per card.
|
/// refresh rather than re-read per card.
|
||||||
profiles: Rc<Vec<(String, String)>>,
|
profiles: Rc<Vec<Profile>>,
|
||||||
/// `Some((id, name))` when this card is a PINNED host+profile pair rather than the
|
/// `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
|
/// 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
|
/// 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 {
|
} else {
|
||||||
pill("Trusted", "pf-accent")
|
pill("Trusted", "pf-accent")
|
||||||
});
|
});
|
||||||
// The chip says what a plain click on THIS card will do: its own profile on a
|
// The chip says what a plain click on THIS card will do: its own profile on
|
||||||
// pinned card, the host's binding on the primary one. A binding whose profile
|
// a pinned card, the host's binding on the primary one. Both resolve through
|
||||||
// was deleted shows nothing and resolves as the defaults, which is exactly
|
// the catalog so the chip can carry the profile's colour; a binding whose
|
||||||
// what will happen on connect (design §6).
|
// profile was deleted shows nothing and resolves as the defaults, which is
|
||||||
let chip = pinned.as_ref().map(|(_, name)| name.as_str()).or_else(|| {
|
// exactly what will happen on connect (design §6).
|
||||||
k.profile_id
|
let chip = pinned
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
|
.map(|(id, _)| id.as_str())
|
||||||
.map(|(_, name)| name.as_str())
|
.or(k.profile_id.as_deref())
|
||||||
});
|
.and_then(|id| profiles.iter().find(|p| p.id == id));
|
||||||
if let Some(name) = chip {
|
if let Some(p) = chip {
|
||||||
status.append(&pill(
|
status.append(&profile_pill(p));
|
||||||
name,
|
|
||||||
if pinned.is_some() {
|
|
||||||
"pf-accent"
|
|
||||||
} else {
|
|
||||||
"pf-neutral"
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CardKind::Discovered(_) => {
|
CardKind::Discovered(_) => {
|
||||||
@@ -456,11 +458,9 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
// and its own removal, and deliberately NOT pair/rename/forget — those
|
// 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.
|
// belong to the host, and offering them here would blur what the card is.
|
||||||
let with = gio::Menu::new();
|
let with = gio::Menu::new();
|
||||||
for (label, target) in std::iter::once(("Default settings", "")).chain(
|
for (label, target) in std::iter::once(("Default settings", ""))
|
||||||
profiles
|
.chain(profiles.iter().map(|p| (p.name.as_str(), p.id.as_str())))
|
||||||
.iter()
|
{
|
||||||
.map(|(id, name)| (name.as_str(), id.as_str())),
|
|
||||||
) {
|
|
||||||
let item = gio::MenuItem::new(Some(label), None);
|
let item = gio::MenuItem::new(Some(label), None);
|
||||||
item.set_action_and_target_value(
|
item.set_action_and_target_value(
|
||||||
Some("card.connect-with"),
|
Some("card.connect-with"),
|
||||||
@@ -485,11 +485,9 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
let with = gio::Menu::new();
|
let with = gio::Menu::new();
|
||||||
let bind = gio::Menu::new();
|
let bind = gio::Menu::new();
|
||||||
let pin = gio::Menu::new();
|
let pin = gio::Menu::new();
|
||||||
for (label, id) in std::iter::once(("Default settings", "")).chain(
|
for (label, id) in std::iter::once(("Default settings", ""))
|
||||||
profiles
|
.chain(profiles.iter().map(|p| (p.name.as_str(), p.id.as_str())))
|
||||||
.iter()
|
{
|
||||||
.map(|(id, name)| (name.as_str(), id.as_str())),
|
|
||||||
) {
|
|
||||||
// A checkmark would be the natural cue for the current binding, but
|
// A checkmark would be the natural cue for the current binding, but
|
||||||
// a GMenu radio needs shared state per card; the bound profile is
|
// a GMenu radio needs shared state per card; the bound profile is
|
||||||
// named on the card's chip instead, visible without opening a menu.
|
// 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
|
/// 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).
|
/// 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<HashMap<String, ()>> = 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 {
|
fn saved_key(h: &KnownHost) -> String {
|
||||||
if h.fp_hex.is_empty() {
|
if h.fp_hex.is_empty() {
|
||||||
format!("{}:{}", h.addr, h.port)
|
format!("{}:{}", h.addr, h.port)
|
||||||
@@ -992,11 +1043,15 @@ impl HostsPage {
|
|||||||
.map(|(fp, _)| fp);
|
.map(|(fp, _)| fp);
|
||||||
let library_enabled = self.settings.borrow().library_enabled;
|
let library_enabled = self.settings.borrow().library_enabled;
|
||||||
// One catalog read per refresh, shared by every card's menus and chip.
|
// One catalog read per refresh, shared by every card's menus and chip.
|
||||||
let profiles: Rc<Vec<(String, String)>> = Rc::new(
|
let profiles: Rc<Vec<Profile>> = Rc::new(
|
||||||
pf_client_core::profiles::ProfilesFile::load()
|
pf_client_core::profiles::ProfilesFile::load()
|
||||||
.profiles
|
.profiles
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|p| (p.id, p.name))
|
.map(|p| Profile {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
accent: p.accent,
|
||||||
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1030,9 +1085,10 @@ impl HostsPage {
|
|||||||
// They share the host's live status because they read the same record, and a
|
// 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).
|
// pin whose profile is gone simply doesn't render (design §5.2a).
|
||||||
for id in &k.pinned_profiles {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
|
let (id, name) = (p.id.clone(), p.name.clone());
|
||||||
saved.push_back(HostCard {
|
saved.push_back(HostCard {
|
||||||
// The spinner belongs to whichever card was clicked; a pin has its own
|
// The spinner belongs to whichever card was clicked; a pin has its own
|
||||||
// key so two cards for one host don't both spin.
|
// key so two cards for one host don't both spin.
|
||||||
@@ -1043,7 +1099,7 @@ impl HostsPage {
|
|||||||
profiles: profiles.clone(),
|
profiles: profiles.clone(),
|
||||||
recent: false,
|
recent: false,
|
||||||
library_enabled,
|
library_enabled,
|
||||||
pinned: Some((id.clone(), name.clone())),
|
pinned: Some((id, name)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ impl Touched {
|
|||||||
fn has(&self, key: &str) -> bool {
|
fn has(&self, key: &str) -> bool {
|
||||||
self.0.borrow().contains(key)
|
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.
|
/// `(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.
|
/// `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];
|
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<RefCell<Vec<(String, gtk::Button)>>> = 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.
|
/// 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
|
/// 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
|
// 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.
|
// happens in its callback, so a cancelled prompt leaves the dialog put.
|
||||||
let (dialog, next) = (dialog.clone(), next.clone());
|
let (dialog, next) = (dialog.clone(), next.clone());
|
||||||
prompt_name(&parent, "New profile", "Create", "", move |name| {
|
prompt_name(
|
||||||
|
&parent,
|
||||||
|
"New profile",
|
||||||
|
"Create",
|
||||||
|
"",
|
||||||
|
true,
|
||||||
|
move |name, accent| {
|
||||||
let mut catalog = ProfilesFile::load();
|
let mut catalog = ProfilesFile::load();
|
||||||
if catalog.name_taken(&name, None) {
|
if catalog.name_taken(&name, None) {
|
||||||
return; // the prompt already refuses these; belt and braces
|
return; // the prompt already refuses these; belt and braces
|
||||||
}
|
}
|
||||||
let profile = StreamProfile::new(name);
|
let mut profile = StreamProfile::new(name);
|
||||||
|
profile.accent = accent;
|
||||||
let id = profile.id.clone();
|
let id = profile.id.clone();
|
||||||
catalog.profiles.push(profile);
|
catalog.profiles.push(profile);
|
||||||
if catalog.save().is_ok() {
|
if catalog.save().is_ok() {
|
||||||
*next.borrow_mut() = Some(Scope::Profile(id));
|
*next.borrow_mut() = Some(Scope::Profile(id));
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*next.borrow_mut() = Some(match i {
|
*next.borrow_mut() = Some(match i {
|
||||||
@@ -140,6 +205,26 @@ fn scope_group(
|
|||||||
std::mem::forget(row);
|
std::mem::forget(row);
|
||||||
|
|
||||||
if let Some(active) = active {
|
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()
|
let actions = adw::ActionRow::builder()
|
||||||
.title(&active.name)
|
.title(&active.name)
|
||||||
.subtitle("This profile")
|
.subtitle("This profile")
|
||||||
@@ -197,7 +282,13 @@ fn run_profile_action(
|
|||||||
match action {
|
match action {
|
||||||
ProfileAction::Rename => {
|
ProfileAction::Rename => {
|
||||||
let keep = id.clone();
|
let keep = id.clone();
|
||||||
prompt_name(parent, "Rename profile", "Rename", name, move |new_name| {
|
prompt_name(
|
||||||
|
parent,
|
||||||
|
"Rename profile",
|
||||||
|
"Rename",
|
||||||
|
name,
|
||||||
|
false,
|
||||||
|
move |new_name, _| {
|
||||||
let mut catalog = ProfilesFile::load();
|
let mut catalog = ProfilesFile::load();
|
||||||
if catalog.name_taken(&new_name, Some(&keep)) {
|
if catalog.name_taken(&new_name, Some(&keep)) {
|
||||||
return;
|
return;
|
||||||
@@ -209,7 +300,8 @@ fn run_profile_action(
|
|||||||
*next.borrow_mut() = Some(Scope::Profile(keep.clone()));
|
*next.borrow_mut() = Some(Scope::Profile(keep.clone()));
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ProfileAction::Duplicate => {
|
ProfileAction::Duplicate => {
|
||||||
let mut catalog = ProfilesFile::load();
|
let mut catalog = ProfilesFile::load();
|
||||||
@@ -286,7 +378,8 @@ fn prompt_name(
|
|||||||
heading: &str,
|
heading: &str,
|
||||||
accept: &str,
|
accept: &str,
|
||||||
initial: &str,
|
initial: &str,
|
||||||
on_ok: impl Fn(String) + 'static,
|
with_colour: bool,
|
||||||
|
on_ok: impl Fn(String, Option<String>) + 'static,
|
||||||
) {
|
) {
|
||||||
let dialog = adw::AlertDialog::new(Some(heading), None);
|
let dialog = adw::AlertDialog::new(Some(heading), None);
|
||||||
let entry = adw::EntryRow::builder().title("Name").build();
|
let entry = adw::EntryRow::builder().title("Name").build();
|
||||||
@@ -296,6 +389,20 @@ fn prompt_name(
|
|||||||
.css_classes(["boxed-list"])
|
.css_classes(["boxed-list"])
|
||||||
.build();
|
.build();
|
||||||
list.append(&entry);
|
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<RefCell<Option<String>>> = 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.set_extra_child(Some(&list));
|
||||||
dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]);
|
dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]);
|
||||||
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
|
||||||
@@ -324,7 +431,7 @@ fn prompt_name(
|
|||||||
dialog.connect_response(Some("ok"), move |_, _| {
|
dialog.connect_response(Some("ok"), move |_, _| {
|
||||||
let name = e.text().trim().to_string();
|
let name = e.text().trim().to_string();
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
on_ok(name);
|
on_ok(name, accent.borrow().clone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
dialog.present(Some(parent));
|
dialog.present(Some(parent));
|
||||||
@@ -369,6 +476,9 @@ fn commit_profile(
|
|||||||
if touched.has("hdr_enabled") {
|
if touched.has("hdr_enabled") {
|
||||||
o.hdr_enabled = Some(values.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") {
|
if touched.has("compositor") {
|
||||||
o.compositor = Some(values.compositor.clone());
|
o.compositor = Some(values.compositor.clone());
|
||||||
}
|
}
|
||||||
@@ -867,6 +977,14 @@ pub fn show_scoped(
|
|||||||
the display supports it, tone-mapped otherwise",
|
the display supports it, tone-mapped otherwise",
|
||||||
)
|
)
|
||||||
.build();
|
.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(
|
let decoder_row = ChoiceRow::new(
|
||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
@@ -1176,6 +1294,7 @@ pub fn show_scoped(
|
|||||||
invert_row.set_active(s.invert_scroll);
|
invert_row.set_active(s.invert_scroll);
|
||||||
mic_row.set_active(s.mic_enabled);
|
mic_row.set_active(s.mic_enabled);
|
||||||
hdr_row.set_active(s.hdr_enabled);
|
hdr_row.set_active(s.hdr_enabled);
|
||||||
|
chroma_row.set_active(s.enable_444);
|
||||||
library_row.set_active(s.library_enabled);
|
library_row.set_active(s.library_enabled);
|
||||||
surround_row.set_selected(match s.audio_channels {
|
surround_row.set_selected(match s.audio_channels {
|
||||||
6 => 1,
|
6 => 1,
|
||||||
@@ -1187,130 +1306,132 @@ pub fn show_scoped(
|
|||||||
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
|
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Override tracking (profile scope) ----
|
// ---- Override markers, per-row reset, and the touch that creates an override ----
|
||||||
// Installed after the seed block on purpose: `set_selected`/`set_active` during setup must
|
// One pass per row, because the three are the same fact seen from different sides: the
|
||||||
// not look like the user touching the control, or opening a profile would override every
|
// marker says "this profile changes this", the reset is the only way back (the override
|
||||||
// row in it.
|
// model never infers "not overridden" from a value comparison), and touching the control
|
||||||
if profile_mode {
|
// is what creates it. The marker therefore has to appear ON TOUCH, not on the next open —
|
||||||
macro_rules! on_change {
|
// a user who changes a row and sees nothing has no reason to believe it took.
|
||||||
($row:expr, $key:literal) => {{
|
//
|
||||||
let t = touched.clone();
|
// Wired after the seed block on purpose: `set_selected`/`set_active` during setup must not
|
||||||
$row.connect_changed(move |_| t.mark($key));
|
// look like the user touching anything, or opening a profile would override every row.
|
||||||
}};
|
|
||||||
}
|
|
||||||
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.
|
|
||||||
if let Some(active) = &active {
|
if let Some(active) = &active {
|
||||||
let o = &active.overrides;
|
let o = &active.overrides;
|
||||||
let mark = |row: &adw::PreferencesRow, key: &'static str, overridden: bool| {
|
// Build the marker for one row and hand back a "now it's overridden" switch its
|
||||||
if !overridden {
|
// control's change handler can pull.
|
||||||
return;
|
let mark = |row: &adw::PreferencesRow,
|
||||||
}
|
key: &'static str,
|
||||||
let Some(row) = row.downcast_ref::<adw::ActionRow>() else {
|
overridden: bool|
|
||||||
return;
|
-> Option<Box<dyn Fn()>> {
|
||||||
};
|
let row = row.downcast_ref::<adw::ActionRow>()?;
|
||||||
let dot = gtk::Box::builder()
|
let dot = gtk::Box::builder()
|
||||||
.css_classes(["pf-override-dot"])
|
.css_classes(["pf-override-dot"])
|
||||||
.valign(gtk::Align::Center)
|
.valign(gtk::Align::Center)
|
||||||
|
.visible(overridden)
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(&dot);
|
row.add_prefix(&dot);
|
||||||
let reset = gtk::Button::builder()
|
let reset = gtk::Button::builder()
|
||||||
.icon_name("edit-undo-symbolic")
|
.icon_name("edit-undo-symbolic")
|
||||||
.tooltip_text("Reset to Default settings")
|
.tooltip_text("Reset to Default settings")
|
||||||
.valign(gtk::Align::Center)
|
.valign(gtk::Align::Center)
|
||||||
|
.visible(overridden)
|
||||||
.css_classes(["flat"])
|
.css_classes(["flat"])
|
||||||
.build();
|
.build();
|
||||||
|
{
|
||||||
let (dialog, next, cleared, scope) = (
|
let (dialog, next, cleared, scope) = (
|
||||||
dialog.clone(),
|
dialog.clone(),
|
||||||
next_scope.clone(),
|
next_scope.clone(),
|
||||||
cleared.clone(),
|
cleared.clone(),
|
||||||
scope.clone(),
|
scope.clone(),
|
||||||
);
|
);
|
||||||
|
let touched = touched.clone();
|
||||||
reset.connect_clicked(move |_| {
|
reset.connect_clicked(move |_| {
|
||||||
// Queue the clear and re-open in the same scope: the close handler commits
|
// 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
|
// whatever else was edited first, then drops this field, and the rebuilt
|
||||||
// show the inherited value. One code path builds rows — including this one.
|
// 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);
|
cleared.borrow_mut().insert(key);
|
||||||
*next.borrow_mut() = Some(scope.clone());
|
*next.borrow_mut() = Some(scope.clone());
|
||||||
dialog.close();
|
dialog.close();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
row.add_suffix(&reset);
|
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",
|
"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());
|
choice!(hz_row, "refresh_hz", o.refresh_hz.is_some());
|
||||||
mark(scale_row.widget(), "render_scale", o.render_scale.is_some());
|
choice!(scale_row, "render_scale", o.render_scale.is_some());
|
||||||
mark(
|
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_row.upcast_ref(),
|
||||||
"bitrate_kbps",
|
"bitrate_kbps",
|
||||||
o.bitrate_kbps.is_some(),
|
o.bitrate_kbps.is_some(),
|
||||||
);
|
);
|
||||||
mark(codec_row.widget(), "codec", o.codec.is_some());
|
let t = touched.clone();
|
||||||
mark(hdr_row.upcast_ref(), "hdr_enabled", o.hdr_enabled.is_some());
|
bitrate_row.connect_value_notify(move |_| {
|
||||||
mark(
|
t.mark("bitrate_kbps");
|
||||||
compositor_row.widget(),
|
if let Some(show) = &show {
|
||||||
"compositor",
|
show();
|
||||||
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(),
|
|
||||||
"fullscreen_on_stream",
|
|
||||||
o.fullscreen_on_stream.is_some(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Assemble the category pages (the Apple revamp's map) ----
|
// ---- 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(&bitrate_row);
|
||||||
quality_group.add(codec_row.widget());
|
quality_group.add(codec_row.widget());
|
||||||
quality_group.add(&hdr_row);
|
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).
|
// Decoder and GPU are facts about THIS device's hardware — never per profile (tier G).
|
||||||
if !profile_mode {
|
if !profile_mode {
|
||||||
quality_group.add(decoder_row.widget());
|
quality_group.add(decoder_row.widget());
|
||||||
@@ -1496,6 +1618,7 @@ pub fn show_scoped(
|
|||||||
s.invert_scroll = invert_row.is_active();
|
s.invert_scroll = invert_row.is_active();
|
||||||
s.mic_enabled = mic_row.is_active();
|
s.mic_enabled = mic_row.is_active();
|
||||||
s.hdr_enabled = hdr_row.is_active();
|
s.hdr_enabled = hdr_row.is_active();
|
||||||
|
s.enable_444 = chroma_row.is_active();
|
||||||
s.audio_channels = match surround_row.selected() {
|
s.audio_channels = match surround_row.selected() {
|
||||||
1 => 6,
|
1 => 6,
|
||||||
2 => 8,
|
2 => 8,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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<HostsMsg>,
|
||||||
|
pub settings: Rc<RefCell<crate::trust::Settings>>,
|
||||||
|
pub gamepad: crate::gamepad::GamepadService,
|
||||||
|
pub identity: (String, String),
|
||||||
|
pub sender: ComponentSender<AppModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value following `flag` in argv, if present (`--flag value`).
|
||||||
|
pub fn arg_value(flag: &str) -> Option<String> {
|
||||||
|
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<String> {
|
||||||
|
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<u16>) {
|
||||||
|
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 <addr>:<port> fp=<hex>` 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::<u16>().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<bool> {
|
||||||
|
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<Vec<bool>> = arg_flag("--probe").then(|| probe_all(&known.hosts));
|
||||||
|
let hosts: Vec<serde_json::Value> = 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 <addr>:<port>`.
|
||||||
|
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 <fp|host[:port]> [--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 <name>`; 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::<u16>().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 <fp|host[:port]>` — 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<glib::ExitCode> {
|
||||||
|
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<String> {
|
||||||
|
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=<profile id|name>` 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<crate::library::GameEntry>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
+32
-635
@@ -1,645 +1,42 @@
|
|||||||
//! `punktfunk-session` — the Vulkan session binary (punktfunk-planning
|
//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita).
|
||||||
//! `linux-client-rearchitecture.md`, Phase 1: the software-path presenter MVP, which IS
|
|
||||||
//! the power-user CLI build).
|
|
||||||
//!
|
//!
|
||||||
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
|
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||||
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
|
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||||
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
|
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side
|
|
||||||
//! makes the other connect silently. `--pair <PIN> --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.
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||||
mod console;
|
// 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))]
|
#[cfg(target_os = "linux")]
|
||||||
mod session_main {
|
mod app;
|
||||||
use pf_client_core::gamepad::GamepadService;
|
#[cfg(target_os = "linux")]
|
||||||
use pf_client_core::session::SessionParams;
|
mod cli;
|
||||||
use pf_client_core::trust;
|
// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5).
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
#[cfg(target_os = "linux")]
|
||||||
use std::sync::atomic::AtomicBool;
|
mod shortcuts;
|
||||||
use std::sync::Arc;
|
#[cfg(target_os = "linux")]
|
||||||
use std::time::Duration;
|
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;
|
#[cfg(target_os = "linux")]
|
||||||
pub const EXIT_TRUST_REJECTED: u8 = 3;
|
fn main() -> gtk::glib::ExitCode {
|
||||||
pub const EXIT_PRESENTER_FAILED: u8 = 4;
|
app::run()
|
||||||
|
|
||||||
/// The value following `flag` in argv, if present (`--flag value`).
|
|
||||||
pub(crate) fn arg_value(flag: &str) -> Option<String> {
|
|
||||||
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 <PIN> --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 <id|name>` — 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<String> {
|
|
||||||
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<String>,
|
|
||||||
clipboard_override: Option<bool>,
|
|
||||||
addr: String,
|
|
||||||
port: u16,
|
|
||||||
pin: [u8; 32],
|
|
||||||
identity: (String, String),
|
|
||||||
launch: Option<String>,
|
|
||||||
gamepad: &GamepadService,
|
|
||||||
native: Mode,
|
|
||||||
force_software: Arc<AtomicBool>,
|
|
||||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
|
||||||
) -> 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<Box<dyn FnMut(u32, u32)>> {
|
|
||||||
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<dyn FnMut(u32, u32)>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<bool>) {
|
|
||||||
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|source<TAB>node.name<TAB>description` 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 <PIN>`: 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 \
|
|
||||||
<host[:port]>` 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 <PIN> --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 <path>`: 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 <PIN> --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(any(target_os = "linux", windows))]
|
/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS
|
||||||
fn main() -> std::process::ExitCode {
|
/// (the Mac client lives in clients/apple).
|
||||||
std::process::ExitCode::from(session_main::run())
|
#[cfg(not(target_os = "linux"))]
|
||||||
}
|
|
||||||
|
|
||||||
/// This stub keeps `cargo build --workspace` green elsewhere (the Mac client lives in
|
|
||||||
/// clients/apple).
|
|
||||||
#[cfg(not(any(target_os = "linux", windows)))]
|
|
||||||
fn main() {
|
fn main() {
|
||||||
eprintln!(
|
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
||||||
"punktfunk-session runs on Linux and Windows — the macOS client lives in clients/apple"
|
|
||||||
);
|
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-<slug>.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<PathBuf, String> {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u64>,
|
||||||
|
/// 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<CancelHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AppMsg>,
|
||||||
|
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::<String>);
|
||||||
|
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(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<AppModel>,
|
||||||
|
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<HashMap<String, gdk::Texture>>,
|
||||||
|
/// The Picture each entry currently renders into (rebuilt per render), so async art
|
||||||
|
/// results land on the right card.
|
||||||
|
pics: RefCell<HashMap<String, gtk::Picture>>,
|
||||||
|
/// Screenshot mode: render injected entries only, never touch the network.
|
||||||
|
mock: Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<AppModel>,
|
||||||
|
req: ConnectRequest,
|
||||||
|
mgmt_port: Option<u16>,
|
||||||
|
) {
|
||||||
|
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<AppModel>,
|
||||||
|
req: ConnectRequest,
|
||||||
|
games: Vec<GameEntry>,
|
||||||
|
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<AppModel>,
|
||||||
|
req: ConnectRequest,
|
||||||
|
mgmt_port: Option<u16>,
|
||||||
|
) -> Rc<State> {
|
||||||
|
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<State>) {
|
||||||
|
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<State>, 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<State>, 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<State>, games: &[GameEntry]) {
|
||||||
|
let base = library::base_url(&state.req.addr, state.mgmt_port);
|
||||||
|
let jobs: VecDeque<(String, Vec<String>)> = {
|
||||||
|
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(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<AppModel>,
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<AppModel>,
|
||||||
|
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<AppModel>,
|
||||||
|
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::<Result<[u8; 32], String>>(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<AppModel>,
|
||||||
|
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||||
|
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<AppModel>,
|
||||||
|
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ pub struct SettingsOverlay {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub hdr_enabled: Option<bool>,
|
pub hdr_enabled: Option<bool>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enable_444: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub compositor: Option<String>,
|
pub compositor: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub audio_channels: Option<u8>,
|
pub audio_channels: Option<u8>,
|
||||||
@@ -108,6 +110,9 @@ impl SettingsOverlay {
|
|||||||
if let Some(v) = self.hdr_enabled {
|
if let Some(v) = self.hdr_enabled {
|
||||||
s.hdr_enabled = v;
|
s.hdr_enabled = v;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = self.enable_444 {
|
||||||
|
s.enable_444 = v;
|
||||||
|
}
|
||||||
if let Some(v) = &self.compositor {
|
if let Some(v) = &self.compositor {
|
||||||
s.compositor = v.clone();
|
s.compositor = v.clone();
|
||||||
}
|
}
|
||||||
@@ -180,6 +185,9 @@ impl SettingsOverlay {
|
|||||||
if after.hdr_enabled != before.hdr_enabled {
|
if after.hdr_enabled != before.hdr_enabled {
|
||||||
self.hdr_enabled = Some(after.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 {
|
if after.compositor != before.compositor {
|
||||||
self.compositor = Some(after.compositor.clone());
|
self.compositor = Some(after.compositor.clone());
|
||||||
}
|
}
|
||||||
@@ -231,6 +239,7 @@ impl SettingsOverlay {
|
|||||||
"render_scale" => self.render_scale = None,
|
"render_scale" => self.render_scale = None,
|
||||||
"codec" => self.codec = None,
|
"codec" => self.codec = None,
|
||||||
"hdr_enabled" => self.hdr_enabled = None,
|
"hdr_enabled" => self.hdr_enabled = None,
|
||||||
|
"enable_444" => self.enable_444 = None,
|
||||||
"compositor" => self.compositor = None,
|
"compositor" => self.compositor = None,
|
||||||
"audio_channels" => self.audio_channels = None,
|
"audio_channels" => self.audio_channels = None,
|
||||||
"mic_enabled" => self.mic_enabled = None,
|
"mic_enabled" => self.mic_enabled = None,
|
||||||
|
|||||||
@@ -719,6 +719,13 @@ pub struct Settings {
|
|||||||
/// stores (and the Linux shells, which have no picker yet) load.
|
/// stores (and the Linux shells, which have no picker yet) load.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub adapter: String,
|
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.
|
/// 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
|
/// The presenter handles the display side dynamically either way (HDR10 swapchain
|
||||||
/// where offered, tonemap where not) — off means "never send me 10-bit".
|
/// where offered, tonemap where not) — off means "never send me 10-bit".
|
||||||
@@ -854,6 +861,7 @@ impl Default for Settings {
|
|||||||
codec: "auto".into(),
|
codec: "auto".into(),
|
||||||
decoder: "auto".into(),
|
decoder: "auto".into(),
|
||||||
adapter: String::new(),
|
adapter: String::new(),
|
||||||
|
enable_444: false,
|
||||||
hdr_enabled: true,
|
hdr_enabled: true,
|
||||||
show_stats: true,
|
show_stats: true,
|
||||||
stats_verbosity: None,
|
stats_verbosity: None,
|
||||||
|
|||||||
Reference in New Issue
Block a user