feat(client): the overlay learns absorb + clear, so per-control shells can edit profiles too
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m47s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
ci / web (push) Successful in 1m8s
apple / swift (push) Successful in 5m16s
ci / docs-site (push) Successful in 1m4s
android / android (push) Failing after 9m5s
ci / bench (push) Successful in 5m34s
decky / build-publish (push) Successful in 21s
ci / rust-arm64 (push) Failing after 8m37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
ci / rust (push) Failing after 9m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
deb / build-publish-host (push) Successful in 10m37s
deb / build-publish-client-arm64 (push) Successful in 9m38s
arch / build-publish (push) Successful in 17m48s
deb / build-publish (push) Successful in 13m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m9s
flatpak / build-publish (push) Failing after 9m27s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m47s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
ci / web (push) Successful in 1m8s
apple / swift (push) Successful in 5m16s
ci / docs-site (push) Successful in 1m4s
android / android (push) Failing after 9m5s
ci / bench (push) Successful in 5m34s
decky / build-publish (push) Successful in 21s
ci / rust-arm64 (push) Failing after 8m37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
ci / rust (push) Failing after 9m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
deb / build-publish-host (push) Successful in 10m37s
deb / build-publish-client-arm64 (push) Successful in 9m38s
arch / build-publish (push) Successful in 17m48s
deb / build-publish (push) Successful in 13m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m9s
flatpak / build-publish (push) Failing after 9m27s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m9s
Groundwork for the Windows half of P1, and a dedup of the Linux one. The two shells commit differently: GTK writes once when the dialog closes (so it can hand over a list of touched rows), WinUI writes on every control change (so it can't). `absorb` serves the second shape — give it the effective settings before and after one control fired, and it records the field that moved. That is deliberately NOT the diff-on-save the design rejects. The comparison is against the EFFECTIVE settings, i.e. what the control was actually showing, not against the globals — so setting a value back to whatever the global happens to be still records an override, which is exactly the pin the design wants. It only ever adds; removing an override stays an explicit, separate operation. `clear` is that operation, keyed by the overlay's own field names, with `resolution` as the one alias for the width/height/match-window tri-state a single control drives on every client. The GTK reset path now calls it instead of carrying its own `match` — that second list was already a place where the two could drift, and the next client would have made it a third. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -400,30 +400,12 @@ fn commit_profile(
|
|||||||
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
|
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
|
||||||
}
|
}
|
||||||
// Resets last: a row the user reset may also have fired its change handler on the way
|
// Resets last: a row the user reset may also have fired its change handler on the way
|
||||||
// (a ComboRow re-seeds), and "back to inheriting" is the later, explicit intent.
|
// (a ComboRow re-seeds), and "back to inheriting" is the later, explicit intent. The field
|
||||||
|
// names are the overlay's own, so the list of what can be cleared lives in ONE place —
|
||||||
|
// this used to be a second `match` here, which is exactly how the two drift.
|
||||||
for key in cleared {
|
for key in cleared {
|
||||||
match *key {
|
if !o.clear(key) {
|
||||||
"resolution" => {
|
tracing::warn!(field = key, "reset of an unknown overlay field");
|
||||||
o.match_window = None;
|
|
||||||
o.width = None;
|
|
||||||
o.height = None;
|
|
||||||
}
|
|
||||||
"refresh_hz" => o.refresh_hz = None,
|
|
||||||
"render_scale" => o.render_scale = None,
|
|
||||||
"bitrate_kbps" => o.bitrate_kbps = None,
|
|
||||||
"codec" => o.codec = None,
|
|
||||||
"hdr_enabled" => o.hdr_enabled = None,
|
|
||||||
"compositor" => o.compositor = None,
|
|
||||||
"audio_channels" => o.audio_channels = None,
|
|
||||||
"mic_enabled" => o.mic_enabled = None,
|
|
||||||
"touch_mode" => o.touch_mode = None,
|
|
||||||
"mouse_mode" => o.mouse_mode = None,
|
|
||||||
"invert_scroll" => o.invert_scroll = None,
|
|
||||||
"inhibit_shortcuts" => o.inhibit_shortcuts = None,
|
|
||||||
"gamepad" => o.gamepad = None,
|
|
||||||
"stats_verbosity" => o.stats_verbosity = None,
|
|
||||||
"fullscreen_on_stream" => o.fullscreen_on_stream = None,
|
|
||||||
other => tracing::warn!(field = other, "reset of an unknown overlay field"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = catalog.save() {
|
if let Err(e) = catalog.save() {
|
||||||
|
|||||||
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(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//! `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);
|
||||||
|
}
|
||||||
@@ -143,6 +143,109 @@ impl SettingsOverlay {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record, as overrides, every tier-P field that differs between two settings snapshots.
|
||||||
|
///
|
||||||
|
/// This is for front-ends that commit PER CONTROL rather than per dialog (the WinUI shell
|
||||||
|
/// writes on every change; the GTK one writes once on close). They can't hand over a list
|
||||||
|
/// of touched fields, so they hand over "the effective settings before this control fired"
|
||||||
|
/// and "after": the only field that can differ is the one the user just touched.
|
||||||
|
///
|
||||||
|
/// That is not the diff-on-save this design rejects. The comparison is against the
|
||||||
|
/// EFFECTIVE settings — what the control was showing — not against the globals, so setting
|
||||||
|
/// a value back to what the global happens to be still records an override, which is the
|
||||||
|
/// pin the design asks for. It only ever adds overrides; removing one is an explicit
|
||||||
|
/// reset, which is a different operation.
|
||||||
|
pub fn absorb(&mut self, before: &Settings, after: &Settings) {
|
||||||
|
if after.width != before.width {
|
||||||
|
self.width = Some(after.width);
|
||||||
|
}
|
||||||
|
if after.height != before.height {
|
||||||
|
self.height = Some(after.height);
|
||||||
|
}
|
||||||
|
if after.refresh_hz != before.refresh_hz {
|
||||||
|
self.refresh_hz = Some(after.refresh_hz);
|
||||||
|
}
|
||||||
|
if after.match_window != before.match_window {
|
||||||
|
self.match_window = Some(after.match_window);
|
||||||
|
}
|
||||||
|
if after.bitrate_kbps != before.bitrate_kbps {
|
||||||
|
self.bitrate_kbps = Some(after.bitrate_kbps);
|
||||||
|
}
|
||||||
|
if after.render_scale != before.render_scale {
|
||||||
|
self.render_scale = Some(after.render_scale);
|
||||||
|
}
|
||||||
|
if after.codec != before.codec {
|
||||||
|
self.codec = Some(after.codec.clone());
|
||||||
|
}
|
||||||
|
if after.hdr_enabled != before.hdr_enabled {
|
||||||
|
self.hdr_enabled = Some(after.hdr_enabled);
|
||||||
|
}
|
||||||
|
if after.compositor != before.compositor {
|
||||||
|
self.compositor = Some(after.compositor.clone());
|
||||||
|
}
|
||||||
|
if after.audio_channels != before.audio_channels {
|
||||||
|
self.audio_channels = Some(after.audio_channels);
|
||||||
|
}
|
||||||
|
if after.mic_enabled != before.mic_enabled {
|
||||||
|
self.mic_enabled = Some(after.mic_enabled);
|
||||||
|
}
|
||||||
|
if after.touch_mode != before.touch_mode {
|
||||||
|
self.touch_mode = Some(after.touch_mode.clone());
|
||||||
|
}
|
||||||
|
if after.mouse_mode != before.mouse_mode {
|
||||||
|
self.mouse_mode = Some(after.mouse_mode.clone());
|
||||||
|
}
|
||||||
|
if after.invert_scroll != before.invert_scroll {
|
||||||
|
self.invert_scroll = Some(after.invert_scroll);
|
||||||
|
}
|
||||||
|
if after.inhibit_shortcuts != before.inhibit_shortcuts {
|
||||||
|
self.inhibit_shortcuts = Some(after.inhibit_shortcuts);
|
||||||
|
}
|
||||||
|
if after.gamepad != before.gamepad {
|
||||||
|
self.gamepad = Some(after.gamepad.clone());
|
||||||
|
}
|
||||||
|
if after.stats_verbosity() != before.stats_verbosity() {
|
||||||
|
self.stats_verbosity = Some(after.stats_verbosity());
|
||||||
|
}
|
||||||
|
if after.fullscreen_on_stream != before.fullscreen_on_stream {
|
||||||
|
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop one override by its overlay field name, putting the row back to inheriting. The
|
||||||
|
/// names are the serialised ones, so a UI can carry them as plain strings; `resolution`
|
||||||
|
/// is the one alias, covering the width/height/match-window tri-state a single control
|
||||||
|
/// drives on every client. Returns false for a name this build doesn't know.
|
||||||
|
pub fn clear(&mut self, field: &str) -> bool {
|
||||||
|
match field {
|
||||||
|
"resolution" => {
|
||||||
|
self.width = None;
|
||||||
|
self.height = None;
|
||||||
|
self.match_window = None;
|
||||||
|
}
|
||||||
|
"width" => self.width = None,
|
||||||
|
"height" => self.height = None,
|
||||||
|
"refresh_hz" => self.refresh_hz = None,
|
||||||
|
"match_window" => self.match_window = None,
|
||||||
|
"bitrate_kbps" => self.bitrate_kbps = None,
|
||||||
|
"render_scale" => self.render_scale = None,
|
||||||
|
"codec" => self.codec = None,
|
||||||
|
"hdr_enabled" => self.hdr_enabled = None,
|
||||||
|
"compositor" => self.compositor = None,
|
||||||
|
"audio_channels" => self.audio_channels = None,
|
||||||
|
"mic_enabled" => self.mic_enabled = None,
|
||||||
|
"touch_mode" => self.touch_mode = None,
|
||||||
|
"mouse_mode" => self.mouse_mode = None,
|
||||||
|
"invert_scroll" => self.invert_scroll = None,
|
||||||
|
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
|
||||||
|
"gamepad" => self.gamepad = None,
|
||||||
|
"stats_verbosity" => self.stats_verbosity = None,
|
||||||
|
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// True when the profile overrides nothing — "inherits everything", the state a freshly
|
/// True when the profile overrides nothing — "inherits everything", the state a freshly
|
||||||
/// created profile starts in. Unknown-key carry-through counts: a profile that only holds
|
/// created profile starts in. Unknown-key carry-through counts: a profile that only holds
|
||||||
/// a newer client's field is not empty.
|
/// a newer client's field is not empty.
|
||||||
@@ -373,6 +476,68 @@ mod tests {
|
|||||||
assert_eq!(pin.apply(&moved).bitrate_kbps, 20000);
|
assert_eq!(pin.apply(&moved).bitrate_kbps, 20000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `absorb` records exactly the field a control changed, compares against the EFFECTIVE
|
||||||
|
/// settings (so a value equal to the global is still a pin), and never removes anything.
|
||||||
|
#[test]
|
||||||
|
fn absorb_records_the_touched_field_only() {
|
||||||
|
let base = Settings {
|
||||||
|
bitrate_kbps: 20000,
|
||||||
|
codec: "hevc".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut o = SettingsOverlay::default();
|
||||||
|
|
||||||
|
// One control fires: before = what it was showing, after = what the user picked.
|
||||||
|
let before = o.apply(&base);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.codec = "av1".into();
|
||||||
|
o.absorb(&before, &after);
|
||||||
|
assert_eq!(o.codec.as_deref(), Some("av1"));
|
||||||
|
assert_eq!(o.bitrate_kbps, None, "nothing else may be recorded");
|
||||||
|
|
||||||
|
// Setting it BACK to the global's value is still an override — the pin case. This is
|
||||||
|
// what makes absorb different from diffing against the globals at save time.
|
||||||
|
let before = o.apply(&base);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.codec = "hevc".into();
|
||||||
|
o.absorb(&before, &after);
|
||||||
|
assert_eq!(o.codec.as_deref(), Some("hevc"));
|
||||||
|
let mut moved = base.clone();
|
||||||
|
moved.codec = "h264".into();
|
||||||
|
assert_eq!(o.apply(&moved).codec, "hevc");
|
||||||
|
|
||||||
|
// The stats tier goes through the resolver, not the legacy bool.
|
||||||
|
let before = o.apply(&base);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.set_stats_verbosity(StatsVerbosity::Detailed);
|
||||||
|
o.absorb(&before, &after);
|
||||||
|
assert_eq!(o.stats_verbosity, Some(StatsVerbosity::Detailed));
|
||||||
|
|
||||||
|
// Identical snapshots record nothing.
|
||||||
|
let before = o.apply(&base);
|
||||||
|
let mut o2 = o.clone();
|
||||||
|
o2.absorb(&before, &before);
|
||||||
|
assert_eq!(o2, o);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
|
||||||
|
#[test]
|
||||||
|
fn clear_drops_one_override() {
|
||||||
|
let mut o = SettingsOverlay {
|
||||||
|
width: Some(3840),
|
||||||
|
height: Some(2160),
|
||||||
|
match_window: Some(false),
|
||||||
|
codec: Some("av1".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(o.clear("codec"));
|
||||||
|
assert_eq!(o.codec, None);
|
||||||
|
assert!(o.clear("resolution"));
|
||||||
|
assert_eq!((o.width, o.height, o.match_window), (None, None, None));
|
||||||
|
assert!(o.is_empty());
|
||||||
|
assert!(!o.clear("no_such_field"));
|
||||||
|
}
|
||||||
|
|
||||||
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
|
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
|
||||||
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
|
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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,110 @@
|
|||||||
|
//! 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
@@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user