From 0594056e029202dc74dabcb47d4aae4cc1adb666 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 00:59:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20`punktfunk`=20=E2=80=94=20one=20he?= =?UTF-8?q?adless=20front-end=20over=20the=20brain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 of design/client-architecture-split.md. There were four overlapping, none-complete CLIs: the Linux shell's rich headless verbs, the Windows shell's near-none (it could not start a stream at all), the session's own, and punktfunk-probe's diagnostics. This is the one a script or a plugin should reach for, and it is a FRONT-END, not the brain — policy stays in `pf-client-core` and the GUI shells keep calling it in-process. Shelling out for connects would have traded duplicated code for a duplicated IPC protocol. punktfunk pair | hosts list/add/forget | wake | library | launch | open reachable | speed-test | profiles list | reset Because it runs the same plan builder and the same wake machine as a card click, `launch` and `open` WAKE A SLEEPING HOST — which the Linux shell's exec-style `--connect` never did; it fired a packet at best and dialled into the void. Host references resolve through the shared `resolve_host`, so `punktfunk launch desk` and `punktfunk://connect/desk` cannot disagree about which box "desk" is. Exit codes extend the session's contract so a consumer can branch without parsing prose: 0 ok, 2 connect, 3 trust, 4 renderer, 5 nothing matched what you named, 6 refused because it needs a person. That last one is why `pair` and `reset` check for a terminal before prompting — a CLI that blocks a CI job on a hidden question is a hang, not a UX. Two deliberate non-features. `speed-test` measures and prints but does NOT apply: which layer a bitrate belongs in is a decision the GUI makes with the user (global vs the bound profile), and a CLI silently rewriting a profile is exactly the surprise that rule exists to prevent. And `open` refuses an unknown host rather than pairing it — a URL may never pair or trust on its own, at any surface. `library` keeps TSV as its default output because that is what Decky's consumer parses; `--json` is the door for tools, and the Playnite importer shells to exactly that. The existing shell flags are untouched: they are a frozen compat contract until Decky migrates. Verified on .21 against hand-authored stores: hosts list (TSV and JSON, with each host's resolved profile and pins), profiles list, a launch honouring the binding ("Game"), a one-off overriding it ("Work"), the same through `open` with `profile=`, and the refusal codes (6 for an unknown host, 5 for `punktfunk://pair/...`). Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 1 + clients/cli/Cargo.toml | 28 ++ clients/cli/src/main.rs | 850 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 879 insertions(+) create mode 100644 clients/cli/Cargo.toml create mode 100644 clients/cli/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 64440cd5..2f1e40f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "crates/pyrowave-sys", "crates/libvpl-sys", "clients/probe", + "clients/cli", "clients/linux", "clients/session", "clients/windows", diff --git a/clients/cli/Cargo.toml b/clients/cli/Cargo.toml new file mode 100644 index 00000000..7ae6ddc7 --- /dev/null +++ b/clients/cli/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "punktfunk-cli" +description = "punktfunk — the headless client CLI: pair, list and edit hosts, wake, enumerate a library, start a stream, open a punktfunk:// URL" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +# The binary is `punktfunk` — the command users and plugins type. The crate name carries the +# `-cli` suffix only because `punktfunk` is taken by the workspace's own naming. +[[bin]] +name = "punktfunk" +path = "src/main.rs" + +# Console subsystem, no GUI toolkit: this links `pf-client-core` (the brain) and nothing else +# UI-shaped, which is what lets it ship in every client package without dragging GTK, WinUI or +# Skia along (design/client-architecture-split.md §4). +[target.'cfg(any(target_os = "linux", windows))'.dependencies] +pf-client-core = { path = "../../crates/pf-client-core", default-features = false } +punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[lints] +workspace = true diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs new file mode 100644 index 00000000..eace847b --- /dev/null +++ b/clients/cli/src/main.rs @@ -0,0 +1,850 @@ +//! `punktfunk` — the headless client CLI (design/client-architecture-split.md §4). +//! +//! One console-subsystem binary over the same brain the GUI shells use, so a script gets the +//! same behaviour a click does — including wake-then-connect, which the Linux shell's old +//! exec-style `--connect` never had. It is a FRONT-END, not the brain: policy lives in +//! `pf_client_core`, and the shells call the same functions in-process rather than shelling +//! out to this. That is the whole point of the split — if the GUI shelled out for connects, +//! trust prompts and wake progress would have to squeeze through an IPC contract. +//! +//! Existing surfaces are a frozen compatibility contract and are NOT replaced by this: the +//! Linux shell keeps its headless flags (Decky invokes them), and `punktfunk-probe` stays the +//! diagnostics tool. This is the door new consumers should use — the Playnite importer shells +//! to `punktfunk library --json`. +//! +//! Exit codes extend the session binary's: 0 ok, 2 connect failed, 3 trust rejected, +//! 4 renderer failed, 5 could not resolve what was asked for, 6 refused because it needs a +//! human (pairing, an unknown host). A machine consumer can branch on those without parsing +//! prose. + +#![forbid(unsafe_code)] + +#[cfg(any(target_os = "linux", windows))] +mod cli { + use pf_client_core::deeplink::{self, DeepLink, HostResolution}; + use pf_client_core::orchestrate::{ + self, ConnectPlan, PlanOutcome, SessionEvent, WakeOutcome, WakeWait, + }; + use pf_client_core::profiles::ProfilesFile; + use pf_client_core::trust::{self, KnownHost, KnownHosts}; + use pf_client_core::{library, wol}; + use std::time::Duration; + + pub const OK: u8 = 0; + pub const CONNECT_FAILED: u8 = 2; + pub const TRUST_REJECTED: u8 = 3; + pub const RENDERER_FAILED: u8 = 4; + /// Nothing here matches what you named (host, profile, game). + pub const UNRESOLVED: u8 = 5; + /// Refused because it needs a person: pairing, or trusting an unknown host. + pub const NEEDS_INTERACTION: u8 = 6; + + const PROBE_TIMEOUT: Duration = Duration::from_millis(2500); + + const USAGE: &str = "\ +punktfunk — the Punktfunk client, headless + + punktfunk pair [--pin N] [--name LABEL] + punktfunk hosts list [--probe] [--json] + punktfunk hosts add [--name LABEL] [--fp HEX] + punktfunk hosts forget + punktfunk wake [--wait] + punktfunk library [--json] + punktfunk launch [--game ID] [--profile REF] [--exec] [--fullscreen] + punktfunk open + punktfunk reachable + punktfunk speed-test + punktfunk profiles list [--json] + punktfunk reset + +A is a saved host's id, its name, or an address — the same reference a +punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not found, +6 needs a person."; + + /// The value after `--flag`, if any. + fn value(args: &[String], flag: &str) -> Option { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .filter(|v| !v.starts_with("--")) + .cloned() + } + + fn has(args: &[String], flag: &str) -> bool { + args.iter().any(|a| a == flag) + } + + /// The first argument that isn't a flag or a flag's value — the verb's subject. + fn positional(args: &[String], skip: usize) -> Option { + let mut out = Vec::new(); + let mut i = 0; + while i < args.len() { + if args[i].starts_with("--") { + // Skip the flag and, when it takes one, its value. + if args + .get(i + 1) + .is_some_and(|v| !v.starts_with("--") && flag_takes_value(&args[i])) + { + i += 1; + } + } else { + out.push(args[i].clone()); + } + i += 1; + } + out.get(skip).cloned() + } + + fn flag_takes_value(flag: &str) -> bool { + matches!( + flag, + "--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" + ) + } + + /// Resolve a host reference the way every other surface does: stable id, then a unique + /// name, then `addr[:port]` (design/client-deep-links.md §2). Sharing `resolve_host` is + /// what keeps `punktfunk launch desk` and `punktfunk://connect/desk` from disagreeing. + fn resolve(reference: &str) -> Result<(KnownHosts, usize), u8> { + let known = KnownHosts::load(); + let link = DeepLink { + host_ref: reference.to_string(), + ..Default::default() + }; + match deeplink::resolve_host(&link, &known) { + HostResolution::Known(i) => Ok((known, i)), + HostResolution::Ambiguous => { + eprintln!( + "more than one saved host is called \"{reference}\" — use its address or id" + ); + Err(UNRESOLVED) + } + HostResolution::Unknown { addr, port, .. } => { + eprintln!( + "{addr}:{port} isn't a saved host — pair it first (punktfunk pair {addr}:{port})" + ); + Err(NEEDS_INTERACTION) + } + HostResolution::Unresolvable => { + eprintln!("no saved host matches \"{reference}\""); + Err(UNRESOLVED) + } + } + } + + pub fn run(args: Vec) -> u8 { + let Some(verb) = args.first().cloned() else { + println!("{USAGE}"); + return OK; + }; + let rest: Vec = args[1..].to_vec(); + match verb.as_str() { + "pair" => pair(&rest), + "hosts" => hosts(&rest), + "wake" => wake(&rest), + "library" => library_cmd(&rest), + "launch" => launch(&rest), + "open" => open(&rest), + "reachable" => reachable(&rest), + "speed-test" => speed_test(&rest), + "profiles" => profiles(&rest), + "reset" => reset(), + "-h" | "--help" | "help" => { + println!("{USAGE}"); + OK + } + "--version" | "version" => { + println!("punktfunk {}", env!("CARGO_PKG_VERSION")); + OK + } + other => { + eprintln!("unknown command \"{other}\"\n\n{USAGE}"); + UNRESOLVED + } + } + } + + /// `pair [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which + /// is the interactive shape; with one it is scriptable. Refuses rather than prompting when + /// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job + /// forever is worse than an exit code. + fn pair(args: &[String]) -> u8 { + let Some(target) = positional(args, 0) else { + eprintln!("usage: punktfunk pair [--pin N]"); + return UNRESOLVED; + }; + let (addr, port) = split_host_port(&target); + let pin = match value(args, "--pin") { + Some(p) => p, + None => { + if !is_tty() { + eprintln!("no --pin and no terminal to ask on"); + return NEEDS_INTERACTION; + } + eprint!("PIN shown on {addr}: "); + let mut line = String::new(); + if std::io::stdin().read_line(&mut line).is_err() { + return NEEDS_INTERACTION; + } + line.trim().to_string() + } + }; + let identity = match trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + eprintln!("client identity: {e:#}"); + return CONNECT_FAILED; + } + }; + let name = value(args, "--name").unwrap_or_else(trust::device_name); + match trust::pair_with_host(&addr, port, &identity, &pin, &name) { + Ok(fp) => { + let fp_hex = trust::hex(&fp); + trust::persist_host(&addr, &addr, port, &fp_hex, true); + trust::forget_placeholder(&addr, port); + println!("paired {addr}:{port} fp={fp_hex}"); + OK + } + Err(e) => { + eprintln!("{}", trust::pair_error_message(&e)); + TRUST_REJECTED + } + } + } + + /// `hosts list|add|forget` over the shared store — the same file the shells and the session + /// read, so a change here shows up there. + fn hosts(args: &[String]) -> u8 { + match positional(args, 0).as_deref() { + Some("list") | None => { + let known = KnownHosts::load(); + let online: Option> = has(args, "--probe").then(|| { + trust::probe_reachable_many( + known + .hosts + .iter() + .map(|h| (h.addr.clone(), h.port)) + .collect(), + PROBE_TIMEOUT, + ) + }); + if has(args, "--json") { + let catalog = ProfilesFile::load(); + let rows: Vec = known + .hosts + .iter() + .enumerate() + .map(|(i, h)| { + serde_json::json!({ + "id": h.id, + "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, + "clipboard_sync": h.clipboard_sync, + "profile": h.profile_id.as_ref() + .and_then(|id| catalog.find_by_id(id)) + .map(|p| serde_json::json!({"id": p.id, "name": p.name})), + "pinned_profiles": h.resolved_pins(&catalog) + .iter() + .map(|p| serde_json::json!({"id": p.id, "name": p.name})) + .collect::>(), + "online": online.as_ref().map(|v| v[i]), + }) + }) + .collect(); + println!("{}", serde_json::json!({ "hosts": rows })); + } else { + for (i, h) in known.hosts.iter().enumerate() { + let state = match online.as_ref().map(|v| v[i]) { + Some(true) => "online", + Some(false) => "offline", + None => "-", + }; + println!( + "{}\t{}:{}\t{}\t{state}", + h.name, + h.addr, + h.port, + if h.paired { "paired" } else { "trusted" } + ); + } + } + OK + } + Some("add") => { + let Some(target) = positional(args, 1) else { + eprintln!("usage: punktfunk hosts add [--name LABEL] [--fp HEX]"); + return UNRESOLVED; + }; + let (addr, port) = split_host_port(&target); + let mut known = KnownHosts::load(); + if known.hosts.iter().any(|h| h.addr == addr && h.port == port) { + eprintln!("{addr}:{port} is already saved"); + return OK; + } + known.hosts.push(KnownHost { + name: value(args, "--name").unwrap_or_else(|| addr.clone()), + addr: addr.clone(), + port, + fp_hex: value(args, "--fp").unwrap_or_default(), + ..Default::default() + }); + match known.save() { + Ok(()) => { + println!("added {addr}:{port}"); + OK + } + Err(e) => { + eprintln!("saving: {e:#}"); + CONNECT_FAILED + } + } + } + Some("forget") => { + let Some(reference) = positional(args, 1) else { + eprintln!("usage: punktfunk hosts forget "); + return UNRESOLVED; + }; + let (mut known, i) = match resolve(&reference) { + Ok(v) => v, + Err(code) => return code, + }; + let gone = known.hosts.remove(i); + match known.save() { + Ok(()) => { + println!("forgot {}", gone.name); + OK + } + Err(e) => { + eprintln!("saving: {e:#}"); + CONNECT_FAILED + } + } + } + Some(other) => { + eprintln!("unknown hosts command \"{other}\" — list, add or forget"); + UNRESOLVED + } + } + } + + /// `wake [--wait]` — a magic packet, and with `--wait` the same bounded + /// wake-and-wait the shells run (`WakeWait`: a packet every 6 s, presence polled every + /// second, 90 s budget). + fn wake(args: &[String]) -> u8 { + let Some(reference) = positional(args, 0) else { + eprintln!("usage: punktfunk wake [--wait]"); + return UNRESOLVED; + }; + let (known, i) = match resolve(&reference) { + Ok(v) => v, + Err(code) => return code, + }; + let host = &known.hosts[i]; + if host.mac.is_empty() { + eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it", host.name); + return UNRESOLVED; + } + if !has(args, "--wait") { + wol::wake(&host.mac, host.addr.parse().ok()); + println!("sent a wake packet to {}", host.name); + return OK; + } + let mut wait = WakeWait::new(); + loop { + let online = trust::probe_reachable_many( + vec![(host.addr.clone(), host.port)], + Duration::from_millis(900), + ) + .first() + .copied() + .unwrap_or(false); + let tick = wait.tick(online); + if tick.send_packet { + wol::wake(&host.mac, host.addr.parse().ok()); + } + match tick.outcome { + Some(WakeOutcome::Online) => { + println!("{} is up after {}s", host.name, tick.seconds); + return OK; + } + Some(WakeOutcome::TimedOut) => { + eprintln!("{} didn't come online within {}s", host.name, tick.seconds); + return CONNECT_FAILED; + } + None => std::thread::sleep(Duration::from_secs(1)), + } + } + } + + /// `library [--json]` — the host's games. TSV by default because that is what + /// Decky's existing consumer parses; `--json` is the door for tools (the Playnite importer + /// shells to exactly this). + fn library_cmd(args: &[String]) -> u8 { + let Some(reference) = positional(args, 0) else { + eprintln!("usage: punktfunk library [--json]"); + return UNRESOLVED; + }; + let (known, i) = match resolve(&reference) { + Ok(v) => v, + Err(code) => return code, + }; + let host = &known.hosts[i]; + let identity = match trust::load_or_create_identity() { + Ok(id) => id, + Err(e) => { + eprintln!("client identity: {e:#}"); + return CONNECT_FAILED; + } + }; + let pin = trust::parse_hex32(&host.fp_hex); + if pin.is_none() { + eprintln!( + "{} isn't paired yet — punktfunk pair {}", + host.name, host.addr + ); + return NEEDS_INTERACTION; + } + match library::fetch_games(&host.addr, library::DEFAULT_MGMT_PORT, &identity, pin) { + Ok(games) => { + if has(args, "--json") { + let rows: Vec = games + .iter() + .map( + |g| serde_json::json!({"id": g.id, "store": g.store, "title": g.title}), + ) + .collect(); + println!("{}", serde_json::json!({ "games": rows })); + } else { + for g in &games { + println!("{}\t{}\t{}", g.id, g.store, g.title); + } + println!("{} game(s)", games.len()); + } + OK + } + Err(e) => { + eprintln!("library: {e}"); + CONNECT_FAILED + } + } + } + + /// `launch [--game ID] [--profile REF] [--exec]` — start a stream, wake included. + /// `--exec` becomes the session process instead of supervising it: under a gamescope wrapper + /// the launched process must BE the streaming one for focus and lifecycle to work. + fn launch(args: &[String]) -> u8 { + let Some(reference) = positional(args, 0) else { + eprintln!("usage: punktfunk launch [--game ID] [--profile REF] [--exec]"); + return UNRESOLVED; + }; + let (known, i) = match resolve(&reference) { + Ok(v) => v, + Err(code) => return code, + }; + let mut plan = ConnectPlan::for_host( + &known.hosts[i], + value(args, "--game").as_deref(), + value(args, "--profile").as_deref(), + ); + if has(args, "--fullscreen") { + plan.settings.fullscreen_on_stream = true; + } + run_plan(plan, has(args, "--exec")) + } + + /// `open ` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and + /// same connect path as a card click; what changes is only where the notices go. + fn open(args: &[String]) -> u8 { + let Some(url) = positional(args, 0) else { + eprintln!("usage: punktfunk open "); + return UNRESOLVED; + }; + let link = match deeplink::parse(&url) { + Ok(l) => l, + Err(e) => { + eprintln!("{}", e.message()); + return UNRESOLVED; + } + }; + let known = KnownHosts::load(); + let outcome = orchestrate::plan_from_link( + &link, + &known, + &ProfilesFile::load(), + &trust::Settings::load(), + ); + match outcome { + Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")), + // A URL may never pair or trust on its own — that is a decision for a person, at a + // surface that can show them the fingerprint. + Ok(PlanOutcome::ConfirmUnknown(u)) => { + eprintln!( + "{} isn't paired with this device — punktfunk pair {}:{}", + u.name.unwrap_or_else(|| u.addr.clone()), + u.addr, + u.port + ); + NEEDS_INTERACTION + } + Ok(PlanOutcome::Unsupported(route)) => { + eprintln!("punktfunk can't open \"{}\" links yet", route.as_str()); + UNRESOLVED + } + Err(e) => { + eprintln!("{}", e.message()); + UNRESOLVED + } + } + } + + /// Wake if needed, then run the session — supervising it, or becoming it under `--exec`. + fn run_plan(plan: ConnectPlan, exec: bool) -> u8 { + if plan.host.fp_hex.is_none() { + eprintln!( + "{} has no pinned fingerprint — punktfunk pair {}", + plan.host.name, plan.host.addr + ); + return NEEDS_INTERACTION; + } + // Wake first when the host is asleep and we know how to reach it. This is the thing the + // old exec-style CLI never did: it fired a packet at best and dialled into the void. + if plan.wake + && !trust::probe_reachable_many( + vec![(plan.host.addr.clone(), plan.host.port)], + Duration::from_millis(900), + ) + .first() + .copied() + .unwrap_or(false) + { + eprintln!("waking {}…", plan.host.name); + let mut wait = WakeWait::new(); + loop { + let online = trust::probe_reachable_many( + vec![(plan.host.addr.clone(), plan.host.port)], + Duration::from_millis(900), + ) + .first() + .copied() + .unwrap_or(false); + let tick = wait.tick(online); + if tick.send_packet { + wol::wake(&plan.host.mac, plan.host.addr.parse().ok()); + } + match tick.outcome { + Some(WakeOutcome::Online) => break, + Some(WakeOutcome::TimedOut) => { + eprintln!("{} didn't come online", plan.host.name); + return CONNECT_FAILED; + } + None => std::thread::sleep(Duration::from_secs(1)), + } + } + } + if let Some(p) = &plan.profile { + eprintln!("streaming with \"{}\"", p.name); + } + if exec { + let e = orchestrate::exec_session(&plan); + eprintln!("couldn't exec the session binary: {e}"); + return RENDERER_FAILED; + } + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = orchestrate::spawn_session(&plan, None, move |ev| { + let _ = tx.send(ev); + }); + if let Err(e) = spawned { + eprintln!("{e}"); + return RENDERER_FAILED; + } + let mut failure: Option<(String, bool)> = None; + while let Ok(ev) = rx.recv() { + match ev { + SessionEvent::Ready => eprintln!("streaming"), + SessionEvent::Error { + msg, + trust_rejected, + } => failure = Some((msg, trust_rejected)), + SessionEvent::Ended(reason) => eprintln!("{reason}"), + SessionEvent::Exited(code) => { + return match failure { + Some((msg, true)) => { + eprintln!("{msg}"); + TRUST_REJECTED + } + Some((msg, false)) => { + eprintln!("{msg}"); + CONNECT_FAILED + } + None if code == 0 => OK, + None => RENDERER_FAILED, + }; + } + } + } + OK + } + + /// `reachable ` — one bounded, mDNS-independent probe. Exit 0 = reachable. + fn reachable(args: &[String]) -> u8 { + let Some(reference) = positional(args, 0) else { + eprintln!("usage: punktfunk reachable "); + return UNRESOLVED; + }; + // An address that isn't saved is still a legitimate thing to probe — this verb answers + // "can I reach this?", not "do I know this?". + let (addr, port) = match resolve(&reference) { + Ok((known, i)) => (known.hosts[i].addr.clone(), known.hosts[i].port), + Err(_) => split_host_port(&reference), + }; + if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) { + println!("reachable {addr}:{port}"); + OK + } else { + eprintln!("unreachable {addr}:{port}"); + CONNECT_FAILED + } + } + + /// `speed-test ` — measure the real data plane and print what it recommends. + /// Deliberately does NOT apply the result: which layer a bitrate belongs in is a decision + /// the GUI makes with the user (bound profile vs global, design §5.3), and a CLI silently + /// rewriting a profile would be the surprise that rule exists to prevent. + fn speed_test(args: &[String]) -> u8 { + let Some(reference) = positional(args, 0) else { + eprintln!("usage: punktfunk speed-test "); + return UNRESOLVED; + }; + let (known, i) = match resolve(&reference) { + Ok(v) => v, + Err(code) => return code, + }; + let host = &known.hosts[i]; + let Some(pin) = trust::parse_hex32(&host.fp_hex) else { + eprintln!("{} isn't paired yet", host.name); + return NEEDS_INTERACTION; + }; + let identity = match trust::load_or_create_identity() { + Ok(id) => id, + Err(e) => { + eprintln!("client identity: {e:#}"); + return CONNECT_FAILED; + } + }; + let client = match punktfunk_core::client::NativeClient::connect( + &host.addr, + host.port, + punktfunk_core::config::Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }, + punktfunk_core::config::CompositorPref::Auto, + punktfunk_core::config::GamepadPref::Auto, + 0, // bitrate_kbps: the host's default; this connect never presents + 0, // video_caps: nothing decodes here + 2, // audio_channels + 0, // video_codecs: the probe carries no video + 0, // preferred_codec + None, // display_hdr + 0, // client_caps: nothing renders a cursor + None, // launch + Some(pin), + Some(identity), + Duration::from_secs(15), + ) { + Ok(c) => c, + Err(e) => { + eprintln!("connect: {e:?}"); + return CONNECT_FAILED; + } + }; + if let Err(e) = client.request_probe(3_000_000, 2_000) { + eprintln!("probe: {e:?}"); + return CONNECT_FAILED; + } + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + std::thread::sleep(Duration::from_millis(250)); + let r = client.probe_result(); + if r.done { + std::thread::sleep(Duration::from_millis(400)); + let r = client.probe_result(); + let recommended = r.throughput_kbps / 10 * 7; + if has(args, "--json") { + println!( + "{}", + serde_json::json!({ + "mbps": f64::from(r.throughput_kbps) / 1000.0, + "loss_pct": r.loss_pct, + "recommended_kbps": recommended, + }) + ); + } else { + println!( + "{:.0} Mbit/s measured · {:.1}% loss · recommended {:.0} Mbit/s", + f64::from(r.throughput_kbps) / 1000.0, + r.loss_pct, + f64::from(recommended) / 1000.0 + ); + } + return OK; + } + if std::time::Instant::now() > deadline { + eprintln!("probe timed out"); + return CONNECT_FAILED; + } + } + } + + /// `profiles list` — the settings profiles this device has, and what each overrides. + fn profiles(args: &[String]) -> u8 { + match positional(args, 0).as_deref() { + Some("list") | None => { + let catalog = ProfilesFile::load(); + if has(args, "--json") { + println!( + "{}", + serde_json::to_string(&catalog).unwrap_or_else(|_| "{}".into()) + ); + } else { + for p in &catalog.profiles { + let n = serde_json::to_value(&p.overrides) + .ok() + .and_then(|v| v.as_object().map(|o| o.len())) + .unwrap_or(0); + println!("{}\t{}\t{n} override(s)", p.id, p.name); + } + } + OK + } + Some(other) => { + eprintln!("unknown profiles command \"{other}\" — list"); + UNRESOLVED + } + } + } + + /// `reset` — forget this device's saved hosts and stream settings. The identity (and so the + /// hosts' record of this device) is deliberately NOT touched: re-pairing is the user's call. + fn reset() -> u8 { + if !is_tty() { + eprintln!("refusing to reset without a terminal to confirm on"); + return NEEDS_INTERACTION; + } + eprint!("Forget every saved host and reset settings? [y/N] "); + let mut line = String::new(); + if std::io::stdin().read_line(&mut line).is_err() || !line.trim().eq_ignore_ascii_case("y") + { + eprintln!("cancelled"); + return OK; + } + let mut known = KnownHosts::load(); + known.hosts.clear(); + let _ = known.save(); + trust::Settings::default().save(); + println!("client state reset"); + OK + } + + fn split_host_port(target: &str) -> (String, u16) { + match target.rsplit_once(':') { + Some((a, p)) => match p.parse() { + Ok(port) => (a.to_string(), port), + Err(_) => (target.to_string(), 9777), + }, + None => (target.to_string(), 9777), + } + } + + /// Is stdin a terminal? Decides whether a verb may ask a question or must refuse with + /// [`NEEDS_INTERACTION`] — a CLI that blocks a CI job on a prompt is a hang, not a UX. + fn is_tty() -> bool { + #[cfg(unix)] + { + // SAFETY-free check: `isatty` via the std file descriptor, without libc. + std::io::IsTerminal::is_terminal(&std::io::stdin()) + } + #[cfg(not(unix))] + { + std::io::IsTerminal::is_terminal(&std::io::stdin()) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + fn argv(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + /// Flags and their values never masquerade as the verb's subject — the bug that makes + /// `launch --profile Work desk` reach for a host called "Work". + #[test] + fn positional_skips_flags_and_their_values() { + assert_eq!( + positional(&argv(&["desk", "--game", "steam:570"]), 0), + Some("desk".into()) + ); + assert_eq!( + positional(&argv(&["--profile", "Work", "desk"]), 0), + Some("desk".into()) + ); + assert_eq!( + positional(&argv(&["--exec", "desk"]), 0), + Some("desk".into()), + "a valueless flag must not swallow the subject" + ); + assert_eq!( + positional(&argv(&["add", "10.0.0.1"]), 1), + Some("10.0.0.1".into()) + ); + assert_eq!(positional(&argv(&["--json"]), 0), None); + } + + #[test] + fn host_port_splitting() { + assert_eq!(split_host_port("desk"), ("desk".into(), 9777)); + assert_eq!(split_host_port("desk:1234"), ("desk".into(), 1234)); + // Not a port: keep the whole thing as the address rather than inventing one. + assert_eq!(split_host_port("desk:nope"), ("desk:nope".into(), 9777)); + } + + #[test] + fn value_reads_the_argument_after_its_flag() { + let a = argv(&["--game", "steam:570", "--exec"]); + assert_eq!(value(&a, "--game"), Some("steam:570".into())); + assert_eq!(value(&a, "--profile"), None); + // A flag followed by another flag has no value. + assert_eq!(value(&argv(&["--profile", "--exec"]), "--profile"), None); + assert!(has(&a, "--exec")); + } + } +} + +#[cfg(any(target_os = "linux", windows))] +fn main() -> std::process::ExitCode { + // Logs to stderr; stdout is the machine interface (TSV/JSON), exactly like the session + // binary's contract. + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()), + ) + .init(); + let args: Vec = std::env::args().skip(1).collect(); + std::process::ExitCode::from(cli::run(args)) +} + +/// Keeps `cargo build --workspace` green on macOS, where the client is clients/apple. +#[cfg(not(any(target_os = "linux", windows)))] +fn main() { + eprintln!("punktfunk runs on Linux and Windows — the macOS client lives in clients/apple"); + std::process::exit(2); +}