Files
punktfunk/clients/cli/src/main.rs
T
enricobuehlerandClaude Fable 5 f4f83202cb
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
ci / web (push) Successful in 1m13s
apple / swift (push) Successful in 1m21s
ci / rust (push) Failing after 2m14s
android / android (push) Canceled after 2m26s
apple / screenshots (push) Canceled after 58s
arch / build-publish (push) Canceled after 2m42s
ci / docs-site (push) Successful in 2m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / rust-arm64 (push) Canceled after 2m47s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 1m38s
deb / build-publish-client-arm64 (push) Canceled after 41s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 10s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 25s
release / apple (push) Canceled after 0s
windows-host / package (push) Canceled after 3m36s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 7s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
feat(core/client): an AU's prefix reaches the decoder while its tail is on the wire
Delivery used to be all-or-nothing: the decoder saw byte 0 only after the
last packet of the AU landed, so the whole transmit time sat in front of
decode. With the slice-streamed wire (previous commit) blocks now arrive
addressable, and a client can opt in (connect's new frame_parts) to
receive each AU's newly-contiguous prefix as Frame::part pieces - offset
tiling, first/last marked, the completing push carrying only the suffix.
A PARTIAL_FRAME-capable decoder then chews slices concurrently with the
remaining network transfer.

The reassembler walks a per-frame cursor over successfully-completed
blocks (failed FEC reconstructs don't advance it), coalesces blocks that
finished out of order into one part, keeps probe filler whole, and stops
short of the final block so the zero-padded tail still trims at
completion. Whole-frame consumers see byte-identical behavior - parts
never flow without the opt-in, and never on PyroWave (its newest-wins
draining assumes whole AUs).

Per-AU accounting keeps its units: OWD/ABR feeds, the inter-arrival
series and the clock-based jump-to-live detector only count completing
deliveries, and FrameChannel::depth() counts AUs so a part-rich queue
can't trip jump-to-live at a fraction of the real backlog. The consumer
contract (gap or orphan part = AU lost: abandon, flush, resync on the
next first) is documented on FramePart; the C ABI keeps parts off until
PunktfunkFrame can express them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00

1023 lines
41 KiB
Rust

//! `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 <host> --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 <host[:port]> [--pin N] [--name LABEL]
punktfunk hosts list [--probe] [--json]
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
punktfunk hosts forget <host-ref>
punktfunk wake <host-ref> [--wait]
punktfunk library <host-ref> [--json]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
punktfunk open <punktfunk://…>
punktfunk reachable <host-ref>
punktfunk speed-test <host-ref>
punktfunk profiles list [--json]
punktfunk reset
A <host-ref> 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.
\"punktfunk help <command>\" (or any command with --help) explains that command.";
/// The long help for one verb — `punktfunk help <verb>`, or `--help` after the verb.
/// Each entry documents its flags and the behaviour a script would need to know
/// (what goes to stdout vs stderr, and which exit codes mean what).
fn verb_help(verb: &str) -> Option<&'static str> {
Some(match verb {
"pair" => {
"\
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
--pin N the PIN the host is showing; without it the command asks, and
refuses (exit 6) when there is no terminal to ask on
--name LABEL the label the host files this device under
(default: this machine's name)
Pairing verifies the host end-to-end and pins its fingerprint in the saved-hosts
store, so every later connect — here, in the desktop client or the console — is
silent. The port defaults to 9777. Prints `paired <addr>:<port> fp=<hex>` on
success; exit 3 if the host refuses or the PIN is wrong."
}
"hosts" => {
"\
punktfunk hosts — the saved-hosts store (shared with the desktop client)
punktfunk hosts list [--probe] [--json]
Every saved host, name TAB addr:port TAB paired/trusted TAB state.
--probe asks each host directly (no mDNS, so routed/VPN hosts answer
too); --json emits one object with per-host detail, profiles included.
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
Save a host by address — the door for a box mDNS never sees (Tailscale,
another subnet). Without --fp it is a placeholder to pair later; with a
64-hex fingerprint it is pinned immediately (still unpaired).
punktfunk hosts forget <host-ref>
Remove a saved host, its pinned fingerprint included. A later connect
must pair or trust it again."
}
"wake" => {
"\
punktfunk wake <host-ref> [--wait] — Wake-on-LAN
Sends a magic packet to a saved host's MAC (learned from its advert while it
was awake; exit 5 if none is known yet). With --wait, keeps sending every 6 s
and polls presence every second for up to 90 s, exiting 0 the moment the host
answers — the same cadence every graphical shell uses."
}
"library" => {
"\
punktfunk library <host-ref> [--json] — the host's game library
TSV on stdout by default (id TAB store TAB title), one game per line; --json
emits {\"games\":[…]} for tools — the Playnite importer shells to exactly
this. Needs a paired host (exit 6 otherwise)."
}
"launch" => {
"\
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
Start a stream — waking the host first if it is asleep and its MAC is known.
The stream runs in the punktfunk-session renderer; this command supervises it
and relays its lifecycle to stderr.
--game ID ask the host to launch this library title into the stream
--profile REF use a settings profile (id or name) for this connect only;
without it the host's own binding applies
--fullscreen start the stream window fullscreen
--exec become the session process instead of supervising it — the
gamescope-wrapper mode, where the launched process must BE
the streaming one for focus and lifecycle to work
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
trusts this device (re-pair), 4 the renderer could not start."
}
"open" => {
"\
punktfunk open <punktfunk://…> — follow a punktfunk:// link, headless
Same parser and same refusal rules as clicking the link in a shell: a
contradicted fingerprint refuses and says so, an ambiguous name refuses
rather than guessing, and an unknown host is never trusted from a URL —
that is a decision for a person, at a surface that can show the fingerprint
(exit 6 points at `punktfunk pair`). --exec as in launch."
}
"reachable" => {
"\
punktfunk reachable <host-ref> — one bounded reachability probe
Asks the host directly (no mDNS), so routed/VPN hosts answer too. The
reference may be an unsaved address — this verb answers \"can I reach it\",
not \"do I know it\". Exit 0 reachable, 2 not; one line either way."
}
"speed-test" => {
"\
punktfunk speed-test <host-ref> [--json] — measure the real data plane
Runs the host's bandwidth probe over an actual session connect and prints the
measured throughput, loss, and the bitrate it recommends. Deliberately does
NOT apply the result: which layer a bitrate belongs in (a bound profile, the
global default) is a decision the GUI makes with the user, and a CLI silently
rewriting settings would be exactly the surprise that rule exists to prevent."
}
"profiles" => {
"\
punktfunk profiles list [--json] — the settings profiles on this device
One line per profile: id TAB name TAB how many settings it overrides.
Profiles are created and edited in the desktop client; a connect uses one via
`punktfunk launch --profile` or a punktfunk:// link that names it."
}
"reset" => {
"\
punktfunk reset — forget every saved host and reset stream settings
Asks for confirmation, and refuses (exit 6) when there is no terminal to ask
on. This device's identity keypair is deliberately kept, so hosts that knew
this machine still recognise it after re-pairing; delete the identity files
from the config directory for a true factory reset."
}
_ => return None,
})
}
/// The value after `--flag`, if any.
fn value(args: &[String], flag: &str) -> Option<String> {
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<String> {
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<String>) -> u8 {
let Some(verb) = args.first().cloned() else {
println!("{USAGE}");
return OK;
};
let rest: Vec<String> = args[1..].to_vec();
// `--help`/`-h` after any verb prints that verb's help — before dispatch, so a verb
// never mistakes the flag for its subject (`punktfunk pair -h` must not dial "-h").
if rest.iter().any(|a| a == "--help" || a == "-h") {
println!("{}", verb_help(&verb).unwrap_or(USAGE));
return OK;
}
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" => match positional(&rest, 0) {
None => {
println!("{USAGE}");
OK
}
Some(topic) => match verb_help(&topic) {
Some(h) => {
println!("{h}");
OK
}
None => {
eprintln!("no command called \"{topic}\"\n\n{USAGE}");
UNRESOLVED
}
},
},
"--version" | "version" => {
println!("punktfunk {}", env!("CARGO_PKG_VERSION"));
OK
}
other => {
eprintln!("unknown command \"{other}\"\n\n{USAGE}");
UNRESOLVED
}
}
}
/// `pair <host[:port]> [--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 <host[:port]> [--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<Vec<bool>> = 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<serde_json::Value> = 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,
"os": h.os,
"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::<Vec<_>>(),
"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 <host[:port]> [--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 <host-ref>");
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 <host-ref> [--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 <host-ref> [--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 <host-ref> [--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 <host-ref> [--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<serde_json::Value> = 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 <host-ref> [--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 <host-ref> [--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 <url>` — 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 <punktfunk://…>");
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}"),
// Persisted by the brain on the way past; nothing to report here.
SessionEvent::Window { .. } => {}
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 <host-ref>` — one bounded, mDNS-independent probe. Exit 0 = reachable.
fn reachable(args: &[String]) -> u8 {
let Some(reference) = positional(args, 0) else {
eprintln!("usage: punktfunk reachable <host-ref>");
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?". Resolved QUIETLY for the same reason:
// `resolve`'s "pair it first" advice is for verbs that need a saved host, and printing
// it here would scold the exact usage this verb documents.
let known = KnownHosts::load();
let link = DeepLink {
host_ref: reference.clone(),
..Default::default()
};
let (addr, port) = match deeplink::resolve_host(&link, &known) {
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
_ => 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 <host-ref>` — 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 <host-ref>");
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
false, // frame_parts: probe/whole-AU consumer
None, // launch
Some(punktfunk_core::client::device_name()),
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 {
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(v: &[&str]) -> Vec<String> {
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));
}
/// Every advertised verb documents itself — a USAGE line without a help entry is a
/// promise `help <verb>` breaks. The overview and each entry must also name the verb.
#[test]
fn every_usage_verb_has_help() {
for verb in [
"pair",
"hosts",
"wake",
"library",
"launch",
"open",
"reachable",
"speed-test",
"profiles",
"reset",
] {
let h = verb_help(verb).unwrap_or_else(|| panic!("no help for {verb}"));
assert!(
h.starts_with(&format!("punktfunk {verb}")),
"help for {verb} must lead with its own invocation"
);
assert!(USAGE.contains(verb), "USAGE must advertise {verb}");
}
assert!(verb_help("bogus").is_none());
}
#[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<String> = 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);
}