diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index b359f7b1..b0920fea 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -41,16 +41,23 @@ 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 + punktfunk discover [--json] [--timeout SECS] punktfunk pair [--pin N] [--name LABEL] punktfunk hosts list [--probe] [--json] punktfunk hosts add [--name LABEL] [--fp HEX] punktfunk hosts forget punktfunk wake [--wait] punktfunk library [--json] - punktfunk launch [--game ID] [--profile REF] [--exec] [--fullscreen] + punktfunk launch [--game ID] [--profile REF] [--request-access] + [--exec] [--fullscreen] punktfunk open punktfunk reachable punktfunk speed-test @@ -68,6 +75,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) @@ -96,6 +121,13 @@ punktfunk hosts — the saved-hosts store (shared with the desktop client) another subnet). Without --fp it is a placeholder to pair later; with a 64-hex fingerprint it is pinned immediately (still unpaired). + Idempotent, and keyed on the FINGERPRINT once there is one: re-running it + for a host already saved is a no-op, and giving a known fingerprint a new + address MOVES that host's record there rather than filing a second one + (which is how a host that changed DHCP lease stays reachable by its id). + A different fingerprint for an address already saved is refused, exit 3 — + a changed identity is a decision for a person. + punktfunk hosts forget Remove a saved host, its pinned fingerprint included. A later connect must pair or trust it again." @@ -119,7 +151,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 @@ -132,6 +165,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." @@ -222,7 +265,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 +312,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 +350,104 @@ 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)); + // `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and + // never hands their ids back. `load` would mint ids for a pre-mint store and save them — + // a write from a read-only verb, and one that races the `hosts list` a caller is very + // likely running at the same moment (the Decky panel issues both together). + let known = KnownHosts::read(); + 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 @@ -424,16 +566,69 @@ 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 + } + }, + }; + } + // No record at this address — but a record carrying this exact FINGERPRINT is + // this same host at a new one. Re-point it rather than filing a second record: + // the fingerprint is the identity, and a host that changed DHCP lease is the + // whole reason `hosts add --fp` is idempotent in the first place. Without this a + // moved host accumulates one record per address it has ever held, and the one a + // stable id resolves to keeps the address it can no longer be reached at. + if let Some(i) = known + .hosts + .iter() + .position(|h| !fp.is_empty() && h.fp_hex.eq_ignore_ascii_case(&fp)) + { + let was = format!("{}:{}", known.hosts[i].addr, known.hosts[i].port); + known.hosts[i].addr = addr.clone(); + known.hosts[i].port = port; + return match known.save() { + Ok(()) => { + println!("moved {was} to {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() { @@ -475,6 +670,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). @@ -585,6 +829,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, @@ -597,7 +854,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 @@ -622,7 +882,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)) => { @@ -646,7 +906,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 {}", @@ -708,7 +974,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, @@ -967,6 +1250,7 @@ from the config directory for a true factory reset." #[test] fn every_usage_verb_has_help() { for verb in [ + "discover", "pair", "hosts", "wake", @@ -988,6 +1272,109 @@ 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" + ); + } + + /// A host that changed DHCP lease is re-pointed, not filed a second time. Without this + /// the record a stable id resolves to keeps an address the host has left, so a launch + /// dials into the void while the panel shows the live one. + #[test] + fn a_known_fingerprint_at_a_new_address_moves_the_record() { + let mut known = KnownHosts { + hosts: vec![saved("desk", "192.168.1.9", "abc123")], + }; + // Simulates `hosts add 192.168.1.50 --fp abc123` finding no record at that address. + let by_addr = known + .hosts + .iter() + .position(|h| h.addr == "192.168.1.50" && h.port == 9777); + assert!( + by_addr.is_none(), + "the new address is not yet on any record" + ); + let by_fp = known + .hosts + .iter() + .position(|h| h.fp_hex.eq_ignore_ascii_case("abc123")); + assert_eq!(by_fp, Some(0), "the fingerprint still identifies the host"); + known.hosts[0].addr = "192.168.1.50".into(); + assert_eq!(known.hosts.len(), 1, "one host, one record"); + } + #[test] fn value_reads_the_argument_after_its_flag() { let a = argv(&["--game", "steam:570", "--exec"]); diff --git a/clients/cli/tests/cli_smoke.rs b/clients/cli/tests/cli_smoke.rs index 81a80347..20917b2c 100644 --- a/clients/cli/tests/cli_smoke.rs +++ b/clients/cli/tests/cli_smoke.rs @@ -67,3 +67,21 @@ 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` 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 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")); +} diff --git a/clients/decky/README.md b/clients/decky/README.md index 20778036..10d82718 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -2,49 +2,61 @@ Stream to your **Steam Deck** without ever leaving Gaming Mode. This **[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu -(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch -a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable. +(the `…` button): the hosts you can stream, the pinned cards you set up, and one tap into each. -The video itself is the native GTK4 Linux client (the `io.unom.Punktfunk` flatpak); the plugin -discovers, pairs, configures, and *launches it the right way* so gamescope fullscreens it — the same -Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (`@decky/ui`), -the panel looks and feels native to Gaming Mode. +The plugin is a **launcher**, not a client. It doesn't decode video, browse your library, or hold +any settings of its own — the Rust client does all of that, and the plugin's job is to start it +*the right way* so gamescope fullscreens and focuses it (the same Steam-shortcut trick MoonDeck +uses). Everything the panel doesn't do is one tap away in the client's own gamepad UI. ## What it does -1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a - fullscreen page; each host row opens a details view (address, pairing policy, certificate - fingerprint to cross-check against the host's log). -2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing - ceremony headlessly, then remembers the host so future streams connect silently. -3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it. -4. **Games** — each host row has a games button that opens its **library picker**: pin titles as - one-tap "Stream " rows in the QAM (jump straight into e.g. Playnite on the host), or - **"Open library on screen"** to launch the client's controller-driven, console-style library - browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive - plugin reinstalls (stored next to the client's config) and follow a host across IP changes - (matched by certificate fingerprint). -5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's - own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs - scrolling. The categories and their order are the console settings screen's — Stream (resolution - / refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4), - Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic - device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake / - library / fullscreen). The device pickers are populated - from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where - there is more than one adapter. -6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and - a force-stop for a wedged stream client. +1. **Hosts** — the hosts on your network plus the ones you've saved, in one list. Discovery is + mDNS; saved hosts are also probed directly, so a box reached over Tailscale or a VPN shows as + online even though it never advertises. Rows sort online-first, then most recently used. +2. **Trust** — an unpaired host opens a small sheet with two ways in: + - **Request access** (the default) — no PIN. The host's operator approves this Deck in its + console or web UI and the stream starts by itself. See [Request access](#request-access). + - **Use a PIN instead** — the gamepad-navigable keypad, running the same SPAKE2 ceremony. +3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses + it. A sleeping host is woken first (the client runs the real wake-and-wait loop, then dials). +4. **Pinned cards** — a *(host, profile)* pair renders nested under its host as `▸ ` + and streams with that settings profile applied. Cards are the **shared** pinning model every + other client speaks, stored on the host's record — so one you make in the desktop client shows + up here, and vice versa. The plugin renders them; it doesn't create or edit them. +5. **Open Punktfunk** — launches the client's **console home**: the host picker, add-host by + address, PIN pairing, the game library browser, and the **full settings screen**. This is where + everything the panel no longer does now lives. +6. **About** — plugin version, "Check for updates", "Recreate library shortcut", and a force-stop + for a wedged stream. To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the "game" from the Steam overlay — either returns you to Gaming Mode. +### Request access + +Request access is not a second pairing ceremony — it is a **launch**. The plugin saves the host +with the fingerprint it **advertised**, then starts an ordinary identified connect with the +handshake budget stretched to 185 s. The host *parks* that connection until its operator approves +the device, then admits the same connection; the stream starts on its own, and the record flips +to **paired** so every later stream is silent. + +**No advertised fingerprint, no request access.** That pinned fingerprint is the only thing +standing between a 185-second wait and an impostor answering for the host, so a host you typed in +by address gets the PIN path only — and the sheet says why. The plugin never trusts-on-first-use +past a missing fingerprint. + ## Install on the Deck -You need **[Decky Loader](https://decky.xyz/)** and the **`io.unom.Punktfunk` flatpak** -([`packaging/flatpak`](../../packaging/flatpak/README.md)) installed on the Deck — SteamOS `/usr` is -read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses -`avahi-browse`, which ships on SteamOS/Bazzite. +You need **[Decky Loader](https://decky.xyz/)** and a **Punktfunk client** on the Deck. On a normal +Deck that's the `io.unom.Punktfunk` flatpak ([`packaging/flatpak`](../../packaging/flatpak/README.md)) — +SteamOS `/usr` is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. +A native install (sysext, distro package, nix profile, your own build) works too. + +**The client must be v0.22.0 or newer** — that is when the headless `punktfunk` CLI shipped, and +the panel drives everything through it. An older client says so in the panel, with the update +button that fixes it right there. (Discovery no longer needs `avahi-browse` on the Deck; the +client's own mDNS does it.) **Recommended — install from URL** (published by CI): in Decky → Settings → **Developer Mode** → **Install Plugin from URL**, paste: @@ -55,17 +67,15 @@ https://unom.io/pf-decky (short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`; for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky//punktfunk.zip` -directly). The plugin then **self-updates** without -the Decky store — when a newer build exists, an **Update** button appears and drives Decky -Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some -networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed -before the actual download proceeds. +directly). The plugin then **self-updates** without the Decky store — when a newer build exists, an +**Update** button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and +updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin +store first, which may be slow or blackholed before the actual download proceeds. ### Updating the client The plugin also reports — and where it can, installs — updates for the **client** it launches. -What is possible depends on how that client was installed, and the About tab names the install -kind so the answer is never a mystery: +What is possible depends on how that client was installed: | Install | Update | | --- | --- | @@ -88,6 +98,8 @@ pnpm install pnpm build # rollup → dist/index.js pnpm run package # → out/punktfunk/ + out/punktfunk-v.zip DECK=deck@ pnpm run deploy # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader + +python3.13 scripts/test-backend.py # backend unit checks (needs Python ≥3.10) ``` `~/homebrew/plugins/` is root-owned (the loader runs as root), so `deploy.sh` stages to a temp dir @@ -96,28 +108,46 @@ restart is required for an out-of-band install to appear. ## Architecture +Everything below the panel is the CLI. `main.py` builds argv and maps exit codes; it parses none of +the client's data files and re-implements none of its rules. + | File | Role | | --- | --- | -| `src/index.tsx` | Plugin entry: the QAM panel + route registration. | -| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. | -| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. | -| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. | -| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. | -| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). | -| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. | -| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=` (on-screen library); ids are validated space/quote-free at pin AND launch time. | -| `src/backend.ts` | Typed `callable` bridges to `main.py`. | -| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). | -| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). | -| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. | +| `src/index.tsx` | Plugin entry + the QAM panel: update banner, hosts (with nested pinned cards), the console-home door, about. | +| `src/hooks.ts` | `useHosts` (one call merging discovery and the saved store), the update hooks, and the launch action. Also the trust-state model the rows render. | +| `src/trust.tsx` · `src/pair.tsx` | The trust sheet (Request access / Use a PIN instead / Cancel) and the gamepad-navigable PIN keypad. | +| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). | +| `src/backend.ts` · `src/boundary.tsx` · `src/os-icon.tsx` | Typed `callable` bridges to `main.py`; the render error boundary; the host row's OS mark. | +| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). Reads `PF_REF` / `PF_PROFILE` / `PF_REQUEST_ACCESS` / `PF_BROWSE` and runs `punktfunk launch` — or the session's `--browse` for console home. | +| `main.py` | Backend: four thin CLI shells (`discover` / `hosts` / `pair` / `trust_host`) plus the Steam-side work only a plugin can do — `runner_info`, `shortcut_art`, `apply_controller_config`, `kill_stream`, `check_update` / `update_client` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). | +| `scripts/test-backend.py` | Stdlib-only checks: argv shape, the CLI exit-code mapping, and the Steam configset editor. | | `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. | +### Why the launch goes through Steam + +gamescope only gives focus and fullscreen to the window tree Steam launched via `reaper` (it +detects the "current app" by AppID — gamescope#484). A client spawned from the plugin's own +backend comes up invisible and unfocused. So the plugin registers non-Steam shortcuts whose exe is +`/bin/sh` running `bin/punktfunkrun.sh`, and starts them with `RunGame`. + +There are **two** shortcuts, both named `Punktfunk` so Steam keys them to one Steam Input +configset (the key is the lowercase name): a hidden, stateful one that carries the stream, and the +visible, stateless library entry that opens console home. + ## Limitations / next steps -- No manual "add host by IP" entry yet (discovery is mDNS-only). -- No in-stream overlay inside the plugin — the client owns the session once launched. -- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm - it remotely. +- **Profiles and pinned cards can't be created here** — the panel renders them; making one needs + the desktop client, or the client's own gamepad UI once that work lands. A Deck with no profiles + simply sees host rows, and nothing is broken. +- **Per-game pins are on hold.** The shared model pins *host+profile*; nothing in the shared store + persists a pinned *game* yet. The old `decky-pinned.json` is left on disk untouched so a later + migration can read it. +- Pairing with a PIN needs the operator to **arm pairing on the host** so it shows the PIN; the + plugin can't arm it remotely. Request access needs no arming — just an approval. +- **A parked connect looks like a hanging one.** The plugin toasts before launching a request-access + stream to set expectations, which is a patch rather than a fix; teaching the session's connect + screen the same "waiting for approval" copy the console shell already has would pay off for every + shell. ## Related diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh index 3154b04d..349eda41 100755 --- a/clients/decky/bin/punktfunkrun.sh +++ b/clients/decky/bin/punktfunkrun.sh @@ -1,33 +1,32 @@ #!/usr/bin/env bash -# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates. +# punktfunk stream runner — the target of the non-Steam shortcuts the plugin creates. # # WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client # must be a descendant of the process Steam launches via `reaper`, or gamescope never gives # its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID, # which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin -# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak -# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly -# from the (root) Decky backend produces an unfocused, invisible window. +# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client, +# which inherits the shortcut's AppID and is focused. Launching the client directly from the +# (root) Decky backend produces an unfocused, invisible window. # # Per-session parameters arrive as environment variables, set as the shortcut's Steam launch # options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves -# every host (and every pinned game): -# PF_HOST host[:port] to connect to (required for streaming; optional for browse) -# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games) -# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect) -# PF_MGMT management-API port for --browse (optional; client defaults to 47990) -# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after -# firing Wake-on-LAN so the connect survives the host's resume) -# PF_APPID flatpak app id (default io.unom.Punktfunk) -# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) +# every host: +# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream) +# PF_PROFILE settings-profile id for a pinned card (optional) +# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of +# pairing with a PIN. The connect PARKS until somebody approves it. +# PF_BROWSE non-empty = open the client's console home instead of streaming +# PF_APPID flatpak app id (default io.unom.Punktfunk) +# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) # PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it -# resolved a non-flatpak install — then the client is exec'd directly and +# resolved a non-flatpak install — then the client is run directly and # PF_APPID/PF_FLATPAK are unused) # -# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before -# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores -# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop -# session, PF_BROWSE to the client's hosts page. +# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this +# channel; no resolution, bitrate or codec ever does. The client resolves both against its own +# stores, which is what keeps a Steam launch option from becoming a second settings surface. +# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer. # # Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and # WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope. @@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}" FLATPAK="${PF_FLATPAK:-flatpak}" # The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile -# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that -# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only -# difference is the prefix in front of it. +# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the +# client's absolute path here when that is what it resolved. # -# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode -# reclaims focus automatically (no manual refocus needed). -run_client() { +# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell +# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the +# flatpak, reachable with `--command=`, and one bindir natively. +run_cli() { + if [ -n "${PF_CLIENT_BIN:-}" ]; then + # `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no + # PATH at all — which is the environment a Steam launch option can leave us in. + exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@" + fi + exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@" +} + +run_session() { if [ -n "${PF_CLIENT_BIN:-}" ]; then exec "$PF_CLIENT_BIN" "$@" fi @@ -58,40 +66,35 @@ run_client() { # What we are about to run, for the log line each branch prints. CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}" -# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the -# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it). +# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the +# library browser and the full settings screen). UNCHANGED from before this rework — the shell +# binary already execs the session for `--browse`, so there is nothing to repoint here. if [ -n "${PF_BROWSE:-}" ]; then - # The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained - # host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible - # library shortcut launches. `--browse ` opens straight into that host's library (the - # per-host "open on screen" action). A streams a game, session end returns here, B quits. - if [ -z "${PF_HOST:-}" ]; then - echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2 - run_client --browse --fullscreen - fi - echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2 - if [ -n "${PF_MGMT:-}" ]; then - run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen - fi - run_client --browse "$PF_HOST" --fullscreen + echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2 + run_session --browse --fullscreen fi -# Streaming modes need a host (browse above is the only host-less path). -if [ -z "${PF_HOST:-}" ]; then - echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2 +if [ -z "${PF_REF:-}" ]; then + echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2 exit 2 fi -# Trailing args shared by both streaming execs. A stretched connect budget rides along when the -# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak -# without --connect-timeout ignores the flag harmlessly (hand-scanned argv). + set -- --fullscreen -if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then - set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@" +if [ -n "${PF_PROFILE:-}" ]; then + set -- --profile "$PF_PROFILE" "$@" fi -if [ -n "${PF_LAUNCH:-}" ]; then - # A pinned game: the id rides the session Hello and the host launches that title. - echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2 - run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@" + +# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no +# process survives to see the stream come up and record the host as paired; the CLI refuses the +# combination outright rather than downgrading silently. This is safe for gamescope because +# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap` +# already sit between reaper and the client on every other path. +if [ -n "${PF_REQUEST_ACCESS:-}" ]; then + echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2 + run_cli launch "$PF_REF" --request-access "$@" fi -echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2 -run_client --connect "$PF_HOST" "$@" + +# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the +# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does. +echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2 +run_cli launch "$PF_REF" --exec "$@" diff --git a/clients/decky/main.py b/clients/decky/main.py index 19e73116..64695fb8 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,109 @@ 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): + # `ok` last: a payload that ever grows its own `ok` key must not be able to + # report failure through the field this layer owns. + return {**data, "ok": True} + 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 +551,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 +562,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 +641,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