forked from unom/punktfunk
fix(client-core): drop nine GTK-client files a stray copy left in the crate
An `rsync` with the wrong destination dropped the Linux shell's sources into `crates/pf-client-core/src/` and they rode along with the previous commit. Nothing referenced them — `lib.rs` never declared the modules, so they were dead weight rather than a build break — but a crate that says it is UI-agnostic must not have a GTK shell sitting in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,697 +0,0 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita).
|
||||
//!
|
||||
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use pf_client_core::{discovery, gamepad, library, trust, video, wol};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod app;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod cli;
|
||||
// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5).
|
||||
#[cfg(target_os = "linux")]
|
||||
mod shortcuts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spawn;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_hosts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_settings;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_trust;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> gtk::glib::ExitCode {
|
||||
app::run()
|
||||
}
|
||||
|
||||
/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS
|
||||
/// (the Mac client lives in clients/apple).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
//! "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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
//! 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,
|
||||
};
|
||||
|
||||
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),
|
||||
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
@@ -1,365 +0,0 @@
|
||||
//! 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
@@ -1,350 +0,0 @@
|
||||
//! 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),
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user