From 48bb1769b4f6f6be0eeda5484f54a9b12e271049 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:26:59 +0200 Subject: [PATCH 01/12] =?UTF-8?q?feat(cli):=20punktfunk=20discover=20?= =?UTF-8?q?=E2=80=94=20browse=20the=20LAN,=20annotated=20against=20what=20?= =?UTF-8?q?you've=20saved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI could do everything with a host except FIND one, so every headless consumer grew its own mDNS: the Decky plugin parses ~120 lines of avahi TXT escaping in Python, which drifts from the host's advert every time a key is added and makes the plugin depend on Avahi being the resolver. `discovery::discover_for(timeout)` is the bounded collector beside the streaming `browse()` the UI uses — same service type, same TXT keys, folded to one row per host. A refreshed advert wins (it carries the newer address), a removal drops the row, and dropping the receiver on the way out stops the worker so a one-shot call can't leak a browse per invocation. The verb annotates each hit against the saved-hosts store rather than handing back two lists to join: `saved`/`paired` are answered by fingerprint first and address second — the same rule every other surface uses. That is what stops a host that moved DHCP lease from reading as new, and stops a different box that inherited the old address from reading as paired. punktfunk discover [--json] [--timeout SECS] Default 3 s, capped at 30 — this is called from a Quick Access panel, and a typo'd `--timeout 3000` would hang that panel with no way to cancel. An empty LAN exits 0: a caller branching on the code is asking whether the browse ran, and it did. --- clients/cli/src/main.rs | 117 ++++++++++++++++- clients/cli/tests/cli_smoke.rs | 15 +++ crates/pf-client-core/src/discovery.rs | 166 +++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 1 deletion(-) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index b359f7b1..4ed81607 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -44,6 +44,7 @@ mod cli { const USAGE: &str = "\ punktfunk — the Punktfunk client, headless + punktfunk discover [--json] [--timeout SECS] punktfunk pair [--pin N] [--name LABEL] punktfunk hosts list [--probe] [--json] punktfunk hosts add [--name LABEL] [--fp HEX] @@ -68,6 +69,24 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not /// (what goes to stdout vs stderr, and which exit codes mean what). fn verb_help(verb: &str) -> Option<&'static str> { Some(match verb { + "discover" => { + "\ +punktfunk discover [--json] [--timeout SECS] — browse the LAN for hosts + +Listens for Punktfunk hosts advertising over mDNS and prints what answered: +name TAB addr:port TAB saved|new TAB paired|unpaired. `saved` means this +device already has a record for it, matched by fingerprint first and address +second — the same rule every other surface joins the two lists by. + + --timeout SECS how long to browse (default 3, capped at 30) — a bounded + call, so a panel can wait for it + --json {\"hosts\":[{\"name\",\"addr\",\"port\",\"fp\",\"pair\",\"id\",\"mgmt\", + \"os\",\"saved\",\"paired\"}]} + +Nothing answering is an answer, not a failure: an empty list exits 0. A host +mDNS never sees (Tailscale, another subnet) will not appear here — save it by +address with `punktfunk hosts add` and it shows in `hosts list --probe`." + } "pair" => { "\ punktfunk pair — enrol this device with a host (PIN ceremony) @@ -222,7 +241,7 @@ from the config directory for a true factory reset." fn flag_takes_value(flag: &str) -> bool { matches!( flag, - "--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" + "--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" | "--timeout" ) } @@ -269,6 +288,7 @@ from the config directory for a true factory reset." return OK; } match verb.as_str() { + "discover" => discover(&rest), "pair" => pair(&rest), "hosts" => hosts(&rest), "wake" => wake(&rest), @@ -306,6 +326,100 @@ from the config directory for a true factory reset." } } + /// How long `discover` browses when nobody says, and the ceiling on what they can ask for. + /// The cap is not politeness: this verb is called from a Quick Access panel, and a typo'd + /// `--timeout 3000` would hang that panel with no way to cancel it. + const DISCOVER_DEFAULT_SECS: f64 = 3.0; + const DISCOVER_MAX_SECS: f64 = 30.0; + + /// `discover [--json] [--timeout SECS]` — browse the LAN over mDNS and print what answered, + /// annotated against the saved-hosts store. + /// + /// The annotation is the point: a caller wants "can I stream this", which is a question + /// about BOTH lists, and joining them itself is how two surfaces end up disagreeing about + /// the same host. So the match rule lives here, once, and is the same one every other + /// surface uses — fingerprint first (survives a DHCP move), address second. + fn discover(args: &[String]) -> u8 { + let secs = value(args, "--timeout") + .and_then(|v| v.parse::().ok()) + .filter(|s| *s > 0.0) + .unwrap_or(DISCOVER_DEFAULT_SECS) + .min(DISCOVER_MAX_SECS); + let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs)); + let known = KnownHosts::load(); + let rows: Vec<( + &pf_client_core::discovery::DiscoveredHost, + Option<&KnownHost>, + )> = found.iter().map(|d| (d, match_saved(&known, d))).collect(); + if has(args, "--json") { + let hosts: Vec = rows + .iter() + .map(|(d, saved)| { + serde_json::json!({ + "name": d.name, + "addr": d.addr, + "port": d.port, + "fp": d.fp_hex, + "pair": d.pair, + "id": d.advertised_id(), + // 0 = not advertised, which is what a consumer's own "no mgmt port" + // already means — an older host simply omits the TXT. + "mgmt": d.mgmt_port.unwrap_or(0), + "os": d.os, + "saved": saved.is_some(), + "paired": saved.is_some_and(|h| h.paired), + }) + }) + .collect(); + println!("{}", serde_json::json!({ "hosts": hosts })); + } else { + for (d, saved) in &rows { + println!( + "{}\t{}:{}\t{}\t{}", + d.name, + d.addr, + d.port, + if saved.is_some() { "saved" } else { "new" }, + if saved.is_some_and(|h| h.paired) { + "paired" + } else { + "unpaired" + }, + ); + } + } + // An empty LAN is an answer, not a failure — a caller branching on the exit code is + // asking "did the browse run", and it did. + OK + } + + /// The saved record an advert belongs to, if any: fingerprint first, address second. + /// + /// Fingerprint FIRST is deliberate and load-bearing — a host that moved to a new DHCP lease + /// still matches its record, and a *different* host that inherited the old address does not + /// inherit its pairing. This is the rule the plugin's `mergeHosts` and the shells' hosts + /// pages already use; keeping one copy is what stops two surfaces disagreeing about whether + /// the box in front of you is paired. + fn match_saved<'a>( + known: &'a KnownHosts, + advert: &pf_client_core::discovery::DiscoveredHost, + ) -> Option<&'a KnownHost> { + known + .hosts + .iter() + .find(|h| { + !h.fp_hex.is_empty() + && !advert.fp_hex.is_empty() + && h.fp_hex.eq_ignore_ascii_case(&advert.fp_hex) + }) + .or_else(|| { + known + .hosts + .iter() + .find(|h| h.addr == advert.addr && h.port == advert.port) + }) + } + /// `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 @@ -967,6 +1081,7 @@ from the config directory for a true factory reset." #[test] fn every_usage_verb_has_help() { for verb in [ + "discover", "pair", "hosts", "wake", diff --git a/clients/cli/tests/cli_smoke.rs b/clients/cli/tests/cli_smoke.rs index 81a80347..90dd1db2 100644 --- a/clients/cli/tests/cli_smoke.rs +++ b/clients/cli/tests/cli_smoke.rs @@ -67,3 +67,18 @@ fn unknown_verbs_refuse_with_the_not_found_code() { let out = punktfunk(&["help", "frobnicate"]); assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5"); } + +/// `discover` documents itself. Help only — the verb itself browses the LAN, which no runner +/// may be asked to do. +/// +/// The Decky panel detects a too-old client by exactly the signature the test above pins +/// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new +/// enough, `discover` is a verb with help rather than an unknown word. +#[test] +fn discover_documents_itself() { + let out = punktfunk(&["help", "discover"]); + assert!(out.status.success(), "discover has its own help topic"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("--timeout"), "discover documents --timeout"); + assert!(stdout.contains("--json"), "discover documents --json"); +} diff --git a/crates/pf-client-core/src/discovery.rs b/crates/pf-client-core/src/discovery.rs index fdeb8fbe..d318b5c5 100644 --- a/crates/pf-client-core/src/discovery.rs +++ b/crates/pf-client-core/src/discovery.rs @@ -4,6 +4,8 @@ //! cards and flip a saved host's online pip when its advert disappears. use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; #[derive(Clone, Debug)] pub struct DiscoveredHost { @@ -31,6 +33,19 @@ pub struct DiscoveredHost { pub os: String, } +impl DiscoveredHost { + /// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one. + /// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being + /// equal is exactly the "no id" signal — read it through here rather than re-deriving it. + pub fn advertised_id(&self) -> &str { + if self.key == self.fullname { + "" + } else { + &self.key + } + } +} + /// One discovery update for the UI's advert map. pub enum DiscoveryEvent { /// A host advert appeared or refreshed (new address, pairing flipped, …). @@ -117,3 +132,154 @@ pub fn browse() -> async_channel::Receiver { .expect("spawn mdns thread"); rx } + +/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the +/// fold — which is where dedupe and removal actually live — is testable without a network. +type Adverts = BTreeMap; + +/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries +/// the newer address — a host that changed DHCP lease re-announces), and a removal drops +/// whichever entry that mDNS fullname produced, whatever it was keyed under. +fn fold(adverts: &mut Adverts, event: DiscoveryEvent) { + match event { + DiscoveryEvent::Resolved(host) => { + adverts.insert(host.key.clone(), host); + } + DiscoveryEvent::Removed { fullname } => { + adverts.retain(|_, h| h.fullname != fullname); + } + } +} + +/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted. +/// +/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that +/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door: +/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened. +pub fn discover_for(timeout: Duration) -> Vec { + let rx = browse(); + let deadline = Instant::now() + timeout; + let mut adverts = Adverts::new(); + while Instant::now() < deadline { + while let Ok(event) = rx.try_recv() { + fold(&mut adverts, event); + } + // A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking + // receive has no timeout, and the whole point of this call is that it is bounded. + std::thread::sleep(Duration::from_millis(50).min(timeout)); + } + while let Ok(event) = rx.try_recv() { + fold(&mut adverts, event); + } + // Dropping the receiver is what stops the worker: its next send fails and the thread exits, + // shutting the daemon down. Without this a one-shot consumer would leak a browse per call. + drop(rx); + sorted(adverts) +} + +/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared +/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list). +fn sorted(adverts: Adverts) -> Vec { + let mut hosts: Vec = adverts.into_values().collect(); + hosts.sort_by_key(|h| { + ( + h.addr.parse::().ok().map(u32::from), + h.addr.clone(), + h.port, + ) + }); + hosts +} + +#[cfg(test)] +mod tests { + use super::*; + + fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost { + DiscoveredHost { + key: key.into(), + fullname: fullname.into(), + name: fullname.split('.').next().unwrap_or("?").into(), + addr: addr.into(), + port: 9777, + fp_hex: "aa".into(), + pair: "required".into(), + mgmt_port: Some(47990), + mac: vec![], + os: String::new(), + } + } + + /// Two adverts for the same host collapse to one row, and the LATER one wins — that is how + /// a host that moved to a new address stops being listed at the stale one. + #[test] + fn refreshed_advert_supersedes_the_earlier_one() { + let mut adverts = Adverts::new(); + fold( + &mut adverts, + DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")), + ); + fold( + &mut adverts, + DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")), + ); + let out = sorted(adverts); + assert_eq!(out.len(), 1, "same key must not render twice"); + assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins"); + } + + /// A host that goes away during the browse window is not in the answer. + #[test] + fn removal_drops_the_advert_it_names() { + let mut adverts = Adverts::new(); + fold( + &mut adverts, + DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")), + ); + fold( + &mut adverts, + DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")), + ); + fold( + &mut adverts, + DiscoveryEvent::Removed { + fullname: "desk._punktfunk._udp.local.".into(), + }, + ); + let out = sorted(adverts); + assert_eq!(out.len(), 1); + assert_eq!(out[0].key, "id-2"); + } + + /// A host with no `id` TXT is keyed by its fullname — and must not then report that + /// fullname as an id, which would send a caller launching against a nonexistent reference. + #[test] + fn advertised_id_is_empty_without_the_txt() { + let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1"); + assert_eq!(named.advertised_id(), "id-1"); + let anonymous = host( + "desk._punktfunk._udp.local.", + "desk._punktfunk._udp.local.", + "10.0.0.1", + ); + assert_eq!(anonymous.advertised_id(), ""); + } + + /// Addresses sort the way a person reads them, not the way strings compare. + #[test] + fn addresses_sort_numerically() { + let mut adverts = Adverts::new(); + for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"] + .into_iter() + .enumerate() + { + fold( + &mut adverts, + DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)), + ); + } + let out = sorted(adverts); + let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect(); + assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]); + } +} From aec02b9d26371ae689634d8cd5be14699ad33af6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:27:29 +0200 Subject: [PATCH 02/12] fix(cli): hosts add --fp fills in an empty fingerprint instead of dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `punktfunk hosts add --fp ` against an address already in the store printed "is already saved" and exited 0 — having done nothing at all. The --fp was silently discarded, so a host saved by address stayed pinless and every later connect refused for want of a fingerprint, with no line anywhere saying why. Three outcomes now, and the difference between them is a trust decision: • no fingerprint on the record, one offered → fill it in, print `updated :` • the same fingerprint offered again → no-op, exit 0 (a panel may retry a step whose state is already correct without having to invent an error to show) • a DIFFERENT fingerprint → refuse, exit 3 The refusal is the important one. A changed identity is a decision for a person at a surface that can show them both — the rule `upsert_trusted` exists to enforce — and quietly overwriting a pin here would be a back door through the pinning the rest of the client is built on. A record still named after its own address takes an offered --name; a label the user chose is theirs and an advert's name must not overwrite it. --- clients/cli/src/main.rs | 164 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 5 deletions(-) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 4ed81607..3b7a201e 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -538,16 +538,44 @@ from the config directory for a true factory reset." return UNRESOLVED; }; let (addr, port) = split_host_port(&target); + let fp = value(args, "--fp").unwrap_or_default(); + let name = value(args, "--name"); 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; + if let Some(i) = known + .hosts + .iter() + .position(|h| h.addr == addr && h.port == port) + { + return match merge_saved_host(&mut known, i, &fp, name.as_deref()) { + AddOutcome::Unchanged => { + eprintln!("{addr}:{port} is already saved"); + OK + } + AddOutcome::Conflict => { + eprintln!( + "{addr}:{port} is already saved with a different fingerprint — \ + forget it first if you really mean to replace it \ + (punktfunk hosts forget {addr}:{port})" + ); + TRUST_REJECTED + } + AddOutcome::Pinned => match known.save() { + Ok(()) => { + println!("updated {addr}:{port}"); + OK + } + Err(e) => { + eprintln!("saving: {e:#}"); + CONNECT_FAILED + } + }, + }; } known.hosts.push(KnownHost { - name: value(args, "--name").unwrap_or_else(|| addr.clone()), + name: name.unwrap_or_else(|| addr.clone()), addr: addr.clone(), port, - fp_hex: value(args, "--fp").unwrap_or_default(), + fp_hex: fp, ..Default::default() }); match known.save() { @@ -589,6 +617,55 @@ from the config directory for a true factory reset." } } + /// What `hosts add` did to a record that was ALREADY saved for this address. + #[derive(Debug, PartialEq, Eq)] + enum AddOutcome { + /// Nothing to do — no fingerprint was offered, or the record already carries this one. + /// Exits 0 on purpose: a panel retrying step 1 of request access must not have to + /// invent an error to show for a state that is already correct. + Unchanged, + /// The record had no fingerprint and now has this one. + Pinned, + /// The record carries a DIFFERENT fingerprint. Refused, never overwritten. + Conflict, + } + + /// `hosts add --fp` against an address that is already saved. The difference between these + /// three is a trust decision, not bookkeeping. + /// + /// Filling in an empty fingerprint is step 1 of request access (design §5): a host found by + /// advert is saved by address first and pinned second. Without it the `--fp` is dropped on + /// the floor and the launch that follows refuses for want of a pin — which is what this did + /// before, silently and with exit 0. + /// + /// A *different* fingerprint is refused because a changed identity is a decision for a + /// person, at a surface that can show them both. That is what `upsert_trusted` exists to + /// enforce; quietly overwriting it here would be a back door through the pinning the rest + /// of the client is built on. + fn merge_saved_host( + known: &mut KnownHosts, + i: usize, + fp: &str, + name: Option<&str>, + ) -> AddOutcome { + let existing = known.hosts[i].fp_hex.clone(); + if fp.is_empty() || existing.eq_ignore_ascii_case(fp) { + return AddOutcome::Unchanged; + } + if !existing.is_empty() { + return AddOutcome::Conflict; + } + known.hosts[i].fp_hex = fp.to_string(); + // Only a record still named after its own address is renamed: a label the user chose is + // theirs, and an advert's name must not quietly overwrite it. + if let Some(label) = name { + if known.hosts[i].name == known.hosts[i].addr { + known.hosts[i].name = label.to_string(); + } + } + AddOutcome::Pinned + } + /// `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). @@ -1103,6 +1180,83 @@ from the config directory for a true factory reset." assert!(verb_help("bogus").is_none()); } + fn saved(name: &str, addr: &str, fp: &str) -> KnownHost { + KnownHost { + name: name.into(), + addr: addr.into(), + port: 9777, + fp_hex: fp.into(), + ..Default::default() + } + } + + /// Step 1 of request access: a host saved by address gains the fingerprint its advert + /// carried. Before this, `hosts add --fp` on an existing record exited 0 having done + /// NOTHING — the launch that followed then refused for want of a pin, and the panel had + /// no way to tell why. + #[test] + fn adding_a_fingerprint_to_a_placeholder_fills_it_in() { + let mut known = KnownHosts { + hosts: vec![saved("192.168.1.9", "192.168.1.9", "")], + }; + assert_eq!( + merge_saved_host(&mut known, 0, "abc123", Some("living-room")), + AddOutcome::Pinned + ); + assert_eq!(known.hosts[0].fp_hex, "abc123"); + assert_eq!( + known.hosts[0].name, "living-room", + "a record still named after its address takes the offered label" + ); + } + + /// A label the user chose is theirs — an advert's name must not overwrite it. + #[test] + fn filling_in_a_fingerprint_keeps_a_user_chosen_name() { + let mut known = KnownHosts { + hosts: vec![saved("Basement rig", "192.168.1.9", "")], + }; + merge_saved_host(&mut known, 0, "abc123", Some("living-room")); + assert_eq!(known.hosts[0].name, "Basement rig"); + } + + /// Idempotent: the panel may retry step 1, and re-offering the fingerprint a record + /// already carries is a state that is already correct, not an error to render. + #[test] + fn re_adding_the_same_fingerprint_changes_nothing() { + let mut known = KnownHosts { + hosts: vec![saved("desk", "192.168.1.9", "ABC123")], + }; + assert_eq!( + merge_saved_host(&mut known, 0, "abc123", None), + AddOutcome::Unchanged, + "fingerprints compare case-insensitively" + ); + // And a bare `hosts add` with no --fp at all leaves the pin alone. + assert_eq!( + merge_saved_host(&mut known, 0, "", None), + AddOutcome::Unchanged + ); + assert_eq!(known.hosts[0].fp_hex, "ABC123"); + } + + /// A changed identity is a decision for a person. Never a silent overwrite — this is the + /// same rule `upsert_trusted` enforces, and a back door here would defeat it everywhere. + #[test] + fn a_different_fingerprint_is_refused_not_overwritten() { + let mut known = KnownHosts { + hosts: vec![saved("desk", "192.168.1.9", "abc123")], + }; + assert_eq!( + merge_saved_host(&mut known, 0, "deadbeef", None), + AddOutcome::Conflict + ); + assert_eq!( + known.hosts[0].fp_hex, "abc123", + "the pin must survive intact" + ); + } + #[test] fn value_reads_the_argument_after_its_flag() { let a = argv(&["--game", "steam:570", "--exec"]); From f84c5b8114e2f66e9082a997aa1235d9aa9840de Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:28:34 +0200 Subject: [PATCH 03/12] =?UTF-8?q?feat(cli):=20launch=20--request-access=20?= =?UTF-8?q?=E2=80=94=20let=20the=20host's=20operator=20admit=20this=20devi?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request access is not a second pairing ceremony, it is a LAUNCH: an ordinary identified connect with the advertised fingerprint pinned and the handshake budget stretched past the host's approval window. The host parks the connection until somebody approves the device in its console or web UI, then admits the same connection and the stream starts by itself. The desktop shells and the console home have had this for a while (`SpawnOpts::persist_paired`, `screens/pair.rs`); headless callers had no door to it. punktfunk launch --request-access Two behaviours, both small: * `connect_timeout_secs = 185`, matching the host's PENDING_APPROVAL_WAIT. Anything shorter gives up while the approval prompt is still on the operator's screen. * `run_plan` records the host as paired on SessionEvent::Ready. That event IS the approval arriving, and it records the pin the session actually connected WITH rather than re-reading the store — the handshake completed against that identity, which is what makes the record true. Every other launch still records nothing: a plain connect proves reachability, not a new trust decision. Refused under `--exec` (exit 5) rather than silently downgraded. Under --exec the CLI BECOMES the session, so no process survives to observe Ready — a quiet downgrade would leave hosts reading "trusted" forever with nobody able to explain why. --- clients/cli/src/main.rs | 68 +++++++++++++++++++++++++++++++--- clients/cli/tests/cli_smoke.rs | 9 +++-- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 3b7a201e..bf993d0c 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -41,6 +41,11 @@ mod cli { const PROBE_TIMEOUT: Duration = Duration::from_millis(2500); + /// The handshake budget `--request-access` runs on. Matches the host's `PENDING_APPROVAL_WAIT` + /// — the connect is PARKED for that long while an operator decides, so anything shorter would + /// give up while the approval prompt is still on their screen. + const REQUEST_ACCESS_TIMEOUT_SECS: u64 = 185; + const USAGE: &str = "\ punktfunk — the Punktfunk client, headless @@ -51,7 +56,8 @@ punktfunk — the Punktfunk client, headless punktfunk hosts forget punktfunk wake [--wait] punktfunk library [--json] - punktfunk launch [--game ID] [--profile REF] [--exec] [--fullscreen] + punktfunk launch [--game ID] [--profile REF] [--request-access] + [--exec] [--fullscreen] punktfunk open punktfunk reachable punktfunk speed-test @@ -138,7 +144,8 @@ this. Needs a paired host (exit 6 otherwise)." } "launch" => { "\ -punktfunk launch [--game ID] [--profile REF] [--exec] [--fullscreen] +punktfunk launch [--game ID] [--profile REF] [--request-access] + [--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 @@ -151,6 +158,16 @@ and relays its lifecycle to stderr. --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 + --request-access + ask the host's operator to let this device in instead of + typing a PIN. The host PARKS the connect until somebody + approves it in its console or web UI (up to ~185 s), then + admits it and the stream starts by itself; the host is + recorded as paired once that happens, so later streams are + silent. Needs the host's fingerprint pinned already + (`punktfunk hosts add --fp `), and cannot be + combined with --exec — under --exec there is no process + left to record the approval. 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." @@ -776,6 +793,19 @@ from the config directory for a true factory reset." eprintln!("usage: punktfunk launch [--game ID] [--profile REF] [--exec]"); return UNRESOLVED; }; + let exec = has(args, "--exec"); + let request_access = has(args, "--request-access"); + // Refused rather than silently downgraded: under `--exec` this process BECOMES the + // session, so nothing survives to see `Ready` and record the approval. A launch that + // quietly dropped the persistence would leave hosts reading "trusted" forever with + // nobody able to say why. + if request_access && exec { + eprintln!( + "--request-access can't be combined with --exec: under --exec there is no \ + process left to record the host's approval" + ); + return UNRESOLVED; + } let (known, i) = match resolve(&reference) { Ok(v) => v, Err(code) => return code, @@ -788,7 +818,10 @@ from the config directory for a true factory reset." if has(args, "--fullscreen") { plan.settings.fullscreen_on_stream = true; } - run_plan(plan, has(args, "--exec")) + if request_access { + plan.connect_timeout_secs = Some(REQUEST_ACCESS_TIMEOUT_SECS); + } + run_plan(plan, exec, request_access) } /// `open ` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and @@ -813,7 +846,7 @@ from the config directory for a true factory reset." &trust::Settings::load(), ); match outcome { - Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")), + Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false), // 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)) => { @@ -837,7 +870,13 @@ from the config directory for a true factory reset." } /// Wake if needed, then run the session — supervising it, or becoming it under `--exec`. - fn run_plan(plan: ConnectPlan, exec: bool) -> u8 { + /// + /// `persist_paired` records the host as *paired* when the child reports ready. Only + /// `launch --request-access` passes true: there, the host parked the connect until an + /// operator approved this device, so `Ready` IS the approval arriving — the same thing + /// `SpawnOpts::persist_paired` means in the GTK shell. Every other launch records nothing, + /// which is correct: a plain connect proves reachability, not a new trust decision. + fn run_plan(plan: ConnectPlan, exec: bool, persist_paired: bool) -> u8 { if plan.host.fp_hex.is_none() { eprintln!( "{} has no pinned fingerprint — punktfunk pair {}", @@ -899,7 +938,24 @@ from the config directory for a true factory reset." let mut failure: Option<(String, bool)> = None; while let Ok(ev) = rx.recv() { match ev { - SessionEvent::Ready => eprintln!("streaming"), + SessionEvent::Ready => { + eprintln!("streaming"); + // The pin we connected WITH, not one re-derived from the store: the record + // is what we are about to rewrite, and the session proved the host holds + // exactly this identity by completing a pinned handshake against it. + if persist_paired { + if let Some(fp_hex) = &plan.host.fp_hex { + trust::persist_host( + &plan.host.name, + &plan.host.addr, + plan.host.port, + fp_hex, + true, + ); + trust::forget_placeholder(&plan.host.addr, plan.host.port); + } + } + } SessionEvent::Error { msg, trust_rejected, diff --git a/clients/cli/tests/cli_smoke.rs b/clients/cli/tests/cli_smoke.rs index 90dd1db2..20917b2c 100644 --- a/clients/cli/tests/cli_smoke.rs +++ b/clients/cli/tests/cli_smoke.rs @@ -68,17 +68,20 @@ fn unknown_verbs_refuse_with_the_not_found_code() { assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5"); } -/// `discover` documents itself. Help only — the verb itself browses the LAN, which no runner -/// may be asked to do. +/// `discover` and `launch --request-access` document themselves. Help only — the verbs +/// themselves browse the LAN and dial a host, which no runner may be asked to do. /// /// The Decky panel detects a too-old client by exactly the signature the test above pins /// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new /// enough, `discover` is a verb with help rather than an unknown word. #[test] -fn discover_documents_itself() { +fn the_request_access_surfaces_document_themselves() { let out = punktfunk(&["help", "discover"]); assert!(out.status.success(), "discover has its own help topic"); let stdout = String::from_utf8_lossy(&out.stdout); assert!(stdout.contains("--timeout"), "discover documents --timeout"); assert!(stdout.contains("--json"), "discover documents --json"); + + let out = punktfunk(&["launch", "--help"]); + assert!(String::from_utf8_lossy(&out.stdout).contains("--request-access")); } From 2fd303e22f6282da2f7d8f69af11f870e8aaf68f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:40:34 +0200 Subject: [PATCH 04/12] refactor(decky): delete the second client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Decky plugin was a second client. It had its own mDNS discovery, its own host-store editor, its own settings UI over the entire client settings store, its own per-game pin store and picker, and its own fullscreen route with three tabs — about 3,000 lines of TypeScript and Python mirroring, in two other languages, things the Rust client already does. Every one of them drifted from the original: the TXT parser fell behind each key the host advert added, the settings screen modelled a subset of a store that kept growing. They existed because when this plugin was written there was nothing headless to ask. There has been since v0.22.0, so this deletes them. GONE, frontend: page.tsx (the fullscreen route), settings.tsx (a seven-page sidebar over the whole store), hostmgmt.tsx (add/edit/forget), library.tsx (the games picker), ui.tsx (row primitives only the page used). GONE, backend: get/set_settings, list/refresh_devices, library, get/set_pins, list_hosts, add/edit/forget_host, probe_host, reset_config, wake, the avahi browse and its TXT parser, and the direct reads of client-known-hosts.json. WHAT REPLACES THE BACKEND is four shells, each about fifteen lines of build-argv-run-parse: discover() -> punktfunk discover --json hosts() -> punktfunk hosts list --probe --json pair() -> punktfunk pair --pin N --name LABEL trust_host() -> punktfunk hosts add --fp HEX --name LABEL trust_host is the ONLY write this backend makes to the client's store, and it goes through the CLI — which writes temp+rename into a user-owned directory, so a root backend driving it cannot lock the desktop client out of its own files. Nothing here opens client-known-hosts.json or client-profiles.json any more; `hosts list --json` returns profile bindings and pinned cards already resolved against the catalog. _cli_argv mirrors the deleted _session_argv exactly, pointed at `punktfunk`: the flatpak app id stays LAST, because flatpak treats everything after it as the app's own argv. The LD_LIBRARY_PATH repair applies unchanged — Decky's PyInstaller leak breaks the flatpak's libcurl whichever binary inside the sandbox is being started. A client too old for a verb now announces itself DETERMINISTICALLY: exit 5 plus `unknown command ""`, mapped to `client-outdated`, which the panel renders as one explanatory row plus the update button that fixes it. That replaces guessing from GTK-init noise, which survives only where the update check still drives `punktfunk-client` directly. KEPT unchanged in mechanism, because only a Decky plugin can do them: runner_info, shortcut_art, apply_controller_config, check_update/update_client, kill_stream. The settings screen is not lost, it moved: console home -> Settings has the same rows over the same store, is gamepad-navigable, and is one tap from this same panel. Per-game pins have no shared equivalent yet — decky-pinned.json is deliberately left ON DISK, untouched, so a later migration can read it. test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the Steam configset editor, which was untested until now and is the riskiest thing that survived: it edits a file holding hundreds of other games' bindings, in place. --- clients/decky/main.py | 937 ++++++-------------------- clients/decky/scripts/test-backend.py | 239 ++++--- clients/decky/src/hostmgmt.tsx | 164 ----- clients/decky/src/library.tsx | 230 ------- clients/decky/src/page.tsx | 596 ---------------- clients/decky/src/settings.tsx | 657 ------------------ clients/decky/src/ui.tsx | 46 -- 7 files changed, 328 insertions(+), 2541 deletions(-) delete mode 100644 clients/decky/src/hostmgmt.tsx delete mode 100644 clients/decky/src/library.tsx delete mode 100644 clients/decky/src/page.tsx delete mode 100644 clients/decky/src/settings.tsx delete mode 100644 clients/decky/src/ui.tsx diff --git a/clients/decky/main.py b/clients/decky/main.py index 19e73116..8107a023 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -6,37 +6,39 @@ STREAM is NOT launched here — it is launched by the frontend through Steam (SteamClient.Apps.RunGame on a hidden non-Steam shortcut that points at ``bin/punktfunkrun.sh``), because gamescope only focuses/fullscreens windows in the process tree Steam launched via ``reaper``. A flatpak spawned from this backend would be invisible/unfocused (gamescope#484). -The backend's jobs are the things Steam can't do: +This backend is a THIN SHELL OVER THE HEADLESS CLI (``punktfunk``, shipped in every package +since v0.22.0), plus the handful of things that are genuinely Steam's business. It used to be +a second client — its own mDNS parser, its own host-store editor, its own settings writer — +and every one of those was a copy of a rule that already lives in Rust, drifting from it. The +rule now has one home; this file builds argv and maps exit codes. -* **discover()** — browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts. -* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak - client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same - identity store the stream uses), so once paired the stream connects silently. -* **library(host, mgmt_port, fp)** — fetch a paired host's game library headlessly via the - flatpak client's ``--library`` mode (mTLS with the client's own identity; TSV on stdout), - so the picker UI can offer games to pin. -* **get_pins() / set_pins()** — the pinned-games store (``decky-pinned.json`` next to the - client's config, so pins survive plugin reinstalls), annotated with live pairing state. -* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to - the frontend so it can create/point the Steam shortcut. -* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON - (resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads. - ``set_settings`` MERGES onto the file: it is shared with the desktop client and the console. -* **list_devices() / refresh_devices()** — the GPUs and audio endpoints the settings tab's - device pickers offer, read from the session binary (``--list-adapters`` / ``--list-audio``) - and cached, since enumerating them costs a Vulkan + PipeWire init. -* **kill_stream()** — force-stop a wedged stream (``flatpak kill``). -* **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's - comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own - install RPC to apply it); the client's depends on how it was installed — a flatpak is compared - by OSTree commit here, anything else is asked of the client itself - (``punktfunk-client --check-update``, which verifies a signed manifest). -* **update_client()** — apply the client update by whichever route that install supports: - ``flatpak update --user``, ``punktfunk-client --apply-update`` (the packaged root helper), or - a refusal carrying the command to run by hand. +Thin CLI shells — each is build argv, run, parse JSON, map the exit code: -The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by -the host advert in ``crates/punktfunk-host/src/discovery.rs``. +* **discover()** — ``punktfunk discover --json``: the LAN's hosts, already annotated with + whether this device has them saved and paired. +* **hosts()** — ``punktfunk hosts list --probe --json``: the saved hosts with a live, + mDNS-independent reachability probe, and their profile bindings and pinned cards already + resolved against the profile catalog. +* **pair(addr, port, pin, name)** — ``punktfunk pair``: the SPAKE2 PIN ceremony. +* **trust_host(addr, port, fp, name)** — ``punktfunk hosts add --fp``: step 1 of request + access, and the ONLY write this backend makes to the client's store. + +Kept because only a Decky plugin can do them: + +* **runner_info()** — resolve flatpak vs native and hand the frontend the wrapper path. +* **shortcut_art()** — base64 grid/hero/logo + icon path for the Steam shortcut. +* **apply_controller_config()** — write the native-touch layout into every Steam account's + configset dir, chowned back to the user (this backend is root; Steam is not). +* **check_update() / update_client()** — the plugin's own registry manifest (Decky's install + RPC needs artifact + SHA-256) and the client's update route. +* **kill_stream()** — force-stop a wedged client. + +What is deliberately NOT here: the stream launch. It goes through Steam +(SteamClient.Apps.RunGame on a non-Steam shortcut pointing at ``bin/punktfunkrun.sh``), +because gamescope only focuses/fullscreens windows in the process tree Steam launched via +``reaper`` — a client spawned from this backend would come up invisible and unfocused +(gamescope#484). Settings, add-host-by-address, the library browser and profile editing are +not here either: they are one shortcut away in the client's own console home. """ import asyncio @@ -54,51 +56,11 @@ import decky # Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml). APP_ID = "io.unom.Punktfunk" -# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host). -SERVICE_TYPE = "_punktfunk._udp" - -# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk. -# The sandbox HOME resolves to the REAL user home (== DECKY_USER_HOME), NOT the per-app -# ~/.var/app/ dir — verified on-device (`flatpak run … sh -c 'echo $HOME'` prints -# /home/deck, and the manifest's `--filesystem=~/.config/punktfunk` grants exactly that path; -# we also pass HOME=DECKY_USER_HOME into `flatpak run`, see _flatpak_env). Pointing here is what -# lets plugin settings actually reach the client AND lets us read the client's known-hosts to -# tell whether THIS device is already paired with a given host. -def _client_config_dir() -> Path: - return Path(decky.DECKY_USER_HOME) / ".config" / "punktfunk" - - -def _settings_path() -> Path: - return _client_config_dir() / "client-gtk-settings.json" - - -def _paired_fingerprints() -> set[str]: - """Host cert fingerprints (lowercase hex) this client has PIN-paired, from the client's - known-hosts store. Keyed by fingerprint so it survives a host changing IP address.""" - try: - data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text()) - except (OSError, json.JSONDecodeError): - return set() - hosts = data.get("hosts", []) if isinstance(data, dict) else [] - return { - h["fp_hex"].lower() - for h in hosts - if isinstance(h, dict) and h.get("paired") and isinstance(h.get("fp_hex"), str) - } - - def _runner_path() -> str: """Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh).""" return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh") -def _pins_path() -> Path: - """The pinned-games store — plugin-owned, but deliberately in the CLIENT's config dir - (like everything else we persist): the plugins dir is root-owned and wiped on - reinstall, while ``~/.config/punktfunk`` survives both.""" - return _client_config_dir() / "decky-pinned.json" - - # --- Steam Input controller config injection (native touchscreen via the ts_n command) -------- # The Deck's touchscreen only reaches the app as native wl_touch when a Steam Input layout with # the "Touchscreen Native Support" (controller_action ts_n) command is active for the game. We @@ -186,39 +148,6 @@ def _upsert_configset_entry(text: str, key: str, source_type: str, source_val: s return text[:last_close] + block + text[last_close:] -def _parse_library_tsv(stdout: str) -> list[dict]: - """Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per - game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title - may itself contain tabs, so split at most twice.""" - games: list[dict] = [] - for line in stdout.splitlines(): - parts = line.split("\t", 2) - if len(parts) == 3: - games.append({"id": parts[0], "store": parts[1], "title": parts[2]}) - return games - - -def _classify_library_error(stderr: str) -> str: - """Map the client's ``library: `` stderr line to a stable error - code for the UI. Substring-matched against the Display strings in - ``crates/pf-client-core/src/library.rs`` — a wording change degrades to ``client-error`` - (generic copy), never a crash.""" - s = stderr.lower() - if "didn't recognize this device" in s: - return "not-paired" - if "pinned fingerprint" in s: - return "pin-mismatch" - if "couldn't reach the host" in s: - return "unreachable" - if "management api returned http" in s: - return "http" - if "display" in s or "gtk" in s: - # A flatpak so old it predates --library falls through to GTK init, which fails - # headless from this backend. - return "client-outdated" - return "client-error" - - # ---------------------------------------------------------------------------------------- # Self-update check (no Decky store). The plugin is distributed via "Install Plugin from # URL" pointing at our Gitea generic registry, so the official store never sees it and @@ -347,9 +276,10 @@ def _flatpak() -> str | None: # settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real # home), so nothing else in this file has to care which one answered. NATIVE_BIN = "punktfunk-client" -# The Vulkan session binary the shell execs to stream — and the only thing that can enumerate -# this device's GPUs and audio endpoints for the settings pickers. -SESSION_BIN = "punktfunk-session" +# The headless CLI — the door this backend does almost everything through (discover, hosts, +# pair, trust). Shipped beside the GTK client in every package since v0.22.0: /app/bin in the +# flatpak, the same bindir as `punktfunk-client` natively. +CLI_BIN = "punktfunk" # Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and # SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix. @@ -405,25 +335,107 @@ def _client_argv() -> list[str] | None: return [native] if native else None -def _session_argv() -> list[str] | None: - """The argv PREFIX that runs the SESSION binary headlessly, or None when it isn't there. +def _cli_argv() -> list[str] | None: + """The argv PREFIX that runs the headless CLI, or None when no client is installed. - The device enumerations the settings pickers need (`--list-adapters`, `--list-audio`) live on - `punktfunk-session`, not on the client: the GTK shell deliberately links no Vulkan itself and - shells out to the session for exactly the same two lists (clients/linux/src/app.rs). The - flatpak installs both binaries into /app/bin, so `--command=` picks the other one; a native - install puts them in the same bindir, so the session is the client's sibling. + Exactly the shape the old ``_session_argv`` used, pointed at ``punktfunk`` instead: the + flatpak ships both binaries in /app/bin so ``--command=`` picks the other one (**the app id + stays LAST** — flatpak treats everything after it as the app's own argv), and a native + install puts the CLI in the same bindir as ``punktfunk-client``, so it is its sibling. """ prefix = _client_argv() if not prefix: return None if prefix[0] == _flatpak(): - # `flatpak run --command= ` — the app id must stay LAST. - return [*prefix[:-1], f"--command={SESSION_BIN}", prefix[-1]] - sibling = Path(prefix[0]).with_name(SESSION_BIN) + return [*prefix[:-1], f"--command={CLI_BIN}", prefix[-1]] + sibling = Path(prefix[0]).with_name(CLI_BIN) return [str(sibling)] if sibling.exists() else None +async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]: + """Run the headless CLI, returning ``(returncode, stdout, stderr)``. SEPARATE pipes: stdout + is the machine interface (JSON/TSV) and stderr carries the log lines, and merging them would + corrupt every payload. ``(-1, "", "")`` when no client is installed or the call times out. + + The same ``_flatpak_env`` repair the client runs needed applies here unchanged — Decky's + PyInstaller ``LD_LIBRARY_PATH`` leak breaks the flatpak's libcurl whatever binary inside the + sandbox is being started.""" + prefix = _cli_argv() + if not prefix: + return -1, "", "" + proc = None + try: + proc = await asyncio.create_subprocess_exec( + *prefix, *args, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=_flatpak_env(), + ) + out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) + rc = proc.returncode if proc.returncode is not None else -1 + return ( + rc, + (out or b"").decode("utf-8", "replace"), + (err or b"").decode("utf-8", "replace"), + ) + except asyncio.TimeoutError: + decky.logger.warning("cli %s timed out", " ".join(args)) + if proc: + try: + proc.kill() + except ProcessLookupError: + pass + return -1, "", "" + except Exception: # noqa: BLE001 + decky.logger.exception("cli %s failed", " ".join(args)) + return -1, "", "" + + +# The CLI's exit-code contract (clients/cli/src/main.rs): 0 ok, 2 connect failed, 3 trust +# rejected, 4 renderer, 5 could not resolve what was asked for, 6 needs a person. Mapped to the +# stable strings the panel renders, so a reworded message can never change what the UI shows. +_CLI_ERRORS = { + 2: "unreachable", + 3: "refused", + 5: "unresolved", + 6: "needs-pairing", +} + + +def _cli_error(rc: int, stderr: str) -> str: + """One stable error code for a nonzero CLI exit. + + The interesting case is a client too old for the verb we just used. That announces itself + DETERMINISTICALLY — exit 5 plus ``unknown command ""`` on stderr — rather than by the + guesswork the GTK headless modes needed, so the panel can say "update the client" with + confidence and offer the button that fixes it.""" + if rc == -1: + return "client-unavailable" + if rc == 5 and "unknown command" in stderr: + return "client-outdated" + return _CLI_ERRORS.get(rc, "client-error") + + +async def _cli_json(args: list[str], timeout: float = 20.0) -> dict: + """Run the CLI and parse its stdout as JSON. ``{"ok": True, **payload}`` on success, else + ``{"ok": False, "error": , "detail": }``. + + A zero exit with unparseable stdout is a failure, not an empty result: silently returning + "no hosts" for a broken client is exactly the answer a user cannot debug.""" + rc, out, err = await _run_cli(args, timeout=timeout) + if rc == 0: + try: + data = json.loads(out) + if isinstance(data, dict): + return {"ok": True, **data} + except json.JSONDecodeError: + decky.logger.warning("cli %s: unparseable output: %s", args[0], out[:200]) + return {"ok": False, "error": "client-error", "detail": "unreadable output"} + code = _cli_error(rc, err) + detail = (err.strip().splitlines() or [f"{args[0]} failed"])[-1] + decky.logger.warning("cli %s failed (rc=%s, %s): %s", args[0], rc, code, detail) + return {"ok": False, "error": code, "detail": detail} + + def _client_is_flatpak() -> bool: """Is the client this plugin actually drives the FLATPAK one? @@ -537,121 +549,6 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in return -1, "", "" -def _parse_audio_endpoints(out: str) -> tuple[list[dict], list[dict]]: - """Split `punktfunk-session --list-audio` into ``(sinks, sources)``. - - Its format is one endpoint per line, ``sink|sourcenode.namedescription``. The - node.name is what gets STORED (it is the stable id the client resolves against), so a line - without one is unusable and dropped; a missing description falls back to the name rather than - rendering a picker entry with no label. Anything else on the line is ignored, so an extra - trailing column in a future client can't break this. - """ - sinks: list[dict] = [] - sources: list[dict] = [] - for line in out.splitlines(): - parts = line.split("\t") - if len(parts) < 3 or not parts[1].strip(): - continue - kind, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip() - entry = {"name": name, "description": description or name} - if kind == "sink": - sinks.append(entry) - elif kind == "source": - sources.append(entry) - return sinks, sources - - -async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[int, str]: - """Run the SESSION binary headlessly, returning ``(returncode, stdout)``; ``(-1, "")`` when - it isn't installed or the call errors/times out. - - Only ever used for the two read-only device enumerations — the launch path goes through the - Steam shortcut and the wrapper script, never through here. The timeout is generous because - `--list-adapters` initialises Vulkan on a cold flatpak.""" - prefix = _session_argv() - if not prefix: - return -1, "" - proc = None - try: - proc = await asyncio.create_subprocess_exec( - *prefix, *session_args, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, - env=_flatpak_env(), - ) - out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) - rc = proc.returncode if proc.returncode is not None else -1 - return rc, (out or b"").decode("utf-8", "replace") - except asyncio.TimeoutError: - decky.logger.warning("session %s timed out", " ".join(session_args)) - if proc: - try: - proc.kill() - except ProcessLookupError: - pass - return -1, "" - except Exception: # noqa: BLE001 - decky.logger.exception("session %s failed", " ".join(session_args)) - return -1, "" - - -# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the -# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability -# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any -# mutation (add/edit/forget/reset/pair) invalidates it so a change shows up immediately. -_HOSTS_TTL_S = 12.0 -_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None} - -# The settings tab's device lists (GPUs / audio endpoints). No TTL: this is hardware, and reading -# it costs a Vulkan + PipeWire init. Held for the life of the plugin backend; `refresh_devices` -# clears it for the user who just plugged a headset in. -_devices_cache: dict = {"data": None} - - -def _invalidate_hosts_cache() -> None: - _hosts_cache["data"] = None - - -def _read_known_hosts() -> list[dict]: - """The saved-hosts store read straight off disk — the fallback for a client too old to have - ``--list-hosts``. Same file the desktop client owns; `online` is left ``None`` (unknown) - because a direct read has no reachability signal.""" - try: - data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text()) - except (OSError, json.JSONDecodeError): - return [] - hosts = data.get("hosts", []) if isinstance(data, dict) else [] - out: list[dict] = [] - for h in hosts: - if not isinstance(h, dict) or not h.get("addr"): - continue - out.append({ - "name": str(h.get("name") or h.get("addr", "")), - "addr": str(h.get("addr", "")), - "port": int(h.get("port", 9777) or 9777), - "fp_hex": str(h.get("fp_hex", "")), - "paired": bool(h.get("paired", False)), - "mac": h.get("mac") if isinstance(h.get("mac"), list) else [], - "last_used": h.get("last_used"), - "online": None, - }) - return out - - -def _mutation_result(rc: int, err: str, op: str) -> dict: - """Map a headless host-store mutation's exit status to a UI-stable result. ``rc == -1`` means - the flatpak call never ran (missing/timed out); a nonzero rc from a client that PREDATES the - mode falls through to GTK init and fails headless — classified ``client-outdated`` so the UI - can prompt an update instead of showing a cryptic error.""" - if rc == 0: - return {"ok": True} - if rc == -1: - return {"ok": False, "error": "client-unavailable"} - code = _classify_library_error(err) - detail = (err.strip().splitlines() or [f"{op} failed"])[-1] - decky.logger.warning("%s failed (rc=%s): %s", op, rc, detail) - return {"ok": False, "error": code, "detail": detail} - - def _field_from(text: str, name: str) -> str: """Pull ``: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``, ``Origin``).""" @@ -663,6 +560,18 @@ def _field_from(text: str, name: str) -> str: return "" +def _looks_outdated(stderr: str) -> bool: + """Does this stderr have the signature of a client too old for the headless flag it was just + handed? Such a client ignores the unknown flag and falls through to GTK init, which fails + with no display — so the give-away is display/GTK noise rather than anything about the flag. + + Narrow on purpose: the CLI announces the same condition deterministically (exit 5 plus + ``unknown command``, see :func:`_cli_error`), and this heuristic is only still here because + the update check drives the GTK client's ``--check-update``, not the CLI.""" + s = stderr.lower() + return "display" in s or "gtk" in s + + async def _client_update_state() -> dict: """Is a newer commit of the flatpak client available in the remote it tracks? The client is a **per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and @@ -730,312 +639,90 @@ async def _native_update_state() -> dict: if rc == -1: return {} # A client predating `--check-update` ignores the flag and falls through to GTK init, which - # fails headless — the same signature the other headless modes classify. - code = _classify_library_error(err) - decky.logger.info("native check-update unavailable (rc=%s, %s)", rc, code) - return {"error": code} if code == "client-outdated" else {} - - -def _split_txt(txt: str) -> list[str]: - """Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting.""" - tokens: list[str] = [] - cur: list[str] = [] - in_quote = False - for ch in txt: - if ch == '"': - if in_quote: - tokens.append("".join(cur)) - cur = [] - in_quote = not in_quote - elif in_quote: - cur.append(ch) - if cur: - tokens.append("".join(cur)) - return tokens - - -def _parse_avahi_browse(stdout: str) -> list[dict]: - """Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``).""" - out: dict[str, dict] = {} - for raw in stdout.splitlines(): - line = raw.strip() - if not line.startswith("="): - continue - parts = line.replace("\\;", "\x00").split(";") - parts = [p.replace("\x00", ";") for p in parts] - if len(parts) < 9: - continue - - name = parts[3] - address = parts[7] - port_str = parts[8] - txt = parts[9] if len(parts) > 9 else "" - - try: - port = int(port_str) - except ValueError: - port = 0 - - props: dict[str, str] = {} - for token in _split_txt(txt): - if "=" in token: - k, v = token.split("=", 1) - props[k] = v - - if props.get("proto") and not props["proto"].startswith("punktfunk/"): - continue - - try: - mgmt = int(props.get("mgmt", "")) - except ValueError: - mgmt = 0 # not advertised (standalone punktfunk1-host) — callers default 47990 - - entry = { - "name": name, - "host": address, - "port": port, - "pair": props.get("pair", "optional"), - "fp": props.get("fp", ""), - "proto": props.get("proto", ""), - "id": props.get("id", ""), - "mgmt": mgmt, - # OS-identity chain for the host row's icon (e.g. "linux/fedora/bazzite"); - # empty on an older host that doesn't advertise it. - "os": props.get("os", ""), - } - key = props.get("id") or f"{address}:{port}" - existing = out.get(key) - # Prefer IPv4 over IPv6 for the user-facing host string. - if existing is None or (":" in existing["host"] and ":" not in address): - out[key] = entry - - return list(out.values()) + # fails headless — that is the signature, and it is the one thing worth reporting here. + outdated = _looks_outdated(err) + decky.logger.info("native check-update unavailable (rc=%s, outdated=%s)", rc, outdated) + return {"error": "client-outdated"} if outdated else {} class Plugin: - async def discover(self) -> list[dict]: - """Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``.""" - avahi = shutil.which("avahi-browse") - if not avahi: - decky.logger.error("avahi-browse not found; install avahi for host discovery") - return [] - try: - proc = await asyncio.create_subprocess_exec( - avahi, "-rpt", SERVICE_TYPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0) - except asyncio.TimeoutError: - proc.kill() - decky.logger.warning("avahi-browse timed out") - return [] - except Exception: # noqa: BLE001 - decky.logger.exception("avahi-browse failed") - return [] - if stderr: - decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace")) - hosts = _parse_avahi_browse(stdout.decode(errors="replace")) - # Mark which hosts THIS device has already paired (by cert fingerprint), so the UI can - # show "Stream" instead of "Pair" — the mDNS `pair` field is the host's policy, not our - # per-device pairing state. - paired = _paired_fingerprints() - for h in hosts: - fp = h.get("fp") or "" - h["paired"] = bool(fp) and fp.lower() in paired - decky.logger.info("discovered %d punktfunk host(s)", len(hosts)) - return hosts + # ---- Thin shells over the headless CLI ------------------------------------------------- + # + # Each is "build argv, run, parse JSON, map the exit code". No parsing of the client's data + # files happens here and no trust rule is re-implemented here: this backend exists because + # Decky's frontend cannot spawn processes, not because it knows anything the client doesn't. - async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict: - """Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode. + async def discover(self) -> dict: + """Browse the LAN for hosts (``punktfunk discover --json``). - The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here. - On success the flatpak persists the host to its known-hosts as paired, so a later - stream connects silently. Returns ``{ok, fp?, error?}``. - """ - flatpak = _flatpak() - if not flatpak: - return {"ok": False, "error": "flatpak-not-found"} - argv = [ - flatpak, "run", "--arch=x86_64", APP_ID, - "--pair", str(pin).strip(), - "--connect", f"{host}:{port}", - "--name", name, - "--host-label", host, - ] - decky.logger.info("pairing: %s", " ".join(argv[:6] + ["", "--connect", f"{host}:{port}"])) - try: - proc = await asyncio.create_subprocess_exec( - *argv, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=_flatpak_env(), - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0) - except asyncio.TimeoutError: - return {"ok": False, "error": "pairing timed out"} - except Exception as exc: # noqa: BLE001 - decky.logger.exception("pairing failed to launch") - return {"ok": False, "error": str(exc)} + ``{ok: True, hosts: [{name, addr, port, fp, pair, id, mgmt, os, saved, paired}]}``, or + ``{ok: False, error}`` — ``client-outdated`` when the installed client predates the + verb, which the panel renders as one explanatory row plus the update button. - out = stdout.decode(errors="replace") - err = stderr.decode(errors="replace") - if proc.returncode == 0 and "paired " in out: + The 12 s budget covers a cold flatpak start on top of the CLI's own 3 s browse.""" + return await _cli_json(["discover", "--json"], timeout=12.0) + + async def hosts(self) -> dict: + """The saved hosts with a live reachability probe + (``punktfunk hosts list --probe --json``). + + ``--probe`` asks each host directly rather than waiting for an advert, so a host reached + over a routed network (Tailscale/VPN) reports online instead of looking dead. Profile + bindings and pinned cards come back already resolved against the profile catalog — + dangling ids dropped, names attached — so the panel renders them without ever opening + ``client-profiles.json``.""" + return await _cli_json(["hosts", "list", "--probe", "--json"], timeout=30.0) + + async def pair(self, addr: str, port: int, pin: str, name: str = "Steam Deck") -> dict: + """The PIN ceremony (``punktfunk pair --pin N --name LABEL``). + + The operator arms pairing on the host, which shows a 4-digit PIN; entering it here + verifies the host end to end and pins its fingerprint, so every later connect is silent. + ``{ok: True}``, or ``{ok: False, error}`` where ``refused`` is a wrong PIN or a host + that isn't armed, and ``unreachable`` is a host that never answered. + + The budget is generous because the ceremony waits on a person at the other end.""" + rc, out, err = await _run_cli( + [ + "pair", f"{addr}:{int(port)}", + "--pin", str(pin).strip(), + "--name", name, + ], + timeout=100.0, + ) + if rc == 0: fp = "" - for tok in out.split(): - if tok.startswith("fp="): - fp = tok[3:] - decky.logger.info("paired %s:%s", host, port) - _invalidate_hosts_cache() # the store gained a paired entry — reflect it next list + for token in out.split(): + if token.startswith("fp="): + fp = token[3:] + decky.logger.info("paired %s:%s", addr, port) return {"ok": True, "fp": fp} - decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip()) - # Surface the client's own one-line reason (wrong PIN / not armed) to the UI. - reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1] - return {"ok": False, "error": reason} + detail = (err.strip().splitlines() or ["pairing failed"])[-1] + decky.logger.warning("pairing failed (rc=%s): %s", rc, detail) + return {"ok": False, "error": _cli_error(rc, err), "detail": detail} - async def wake(self, host: str, port: int = 9777) -> dict: - """Send a Wake-on-LAN magic packet to a saved host via the flatpak client's headless - ``--wake`` mode, so a sleeping host is up by the time the stream ``--connect`` runs. + async def trust_host(self, addr: str, port: int, fp: str, name: str = "") -> dict: + """Step 1 of request access: save the host with the fingerprint it ADVERTISED + (``punktfunk hosts add --fp --name