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")); }