From 927a5714145c6b5cf25b9436d868d2ff098788f4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 16:31:25 +0200 Subject: [PATCH] feat(console): touch-mode setting + request-access pairing + polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the gamepad/console shell (pf-console-ui) to parity with the other clients: - Settings gain a Touchscreen → Touch mode row (Trackpad / Direct pointer / Touch passthrough), the one couch-relevant Settings field the screen lacked. - The pair screen adds the no-PIN delegated-approval path: a "Request access" action (only when the host advertises a fingerprint to pin) opens a connect the host PARKS until the operator approves this device, then persists it as paired. A role-based row model keeps the cursor off stale indices; manual hosts stay PIN-only, matching the desktop shells. - Threads request_access through OverlayAction::Launch and ConnectIntent; the shell shows a "Waiting for approval…" takeover, and the session binary parks on a 185 s budget (PendingApproval → persist-as-paired via on_connected). Auto-wake (WoL) was already implemented end-to-end and is left as-is. Co-Authored-By: Claude Fable 5 --- clients/session/src/console.rs | 56 ++++++- crates/pf-console-ui/src/screens.rs | 4 + crates/pf-console-ui/src/screens/home.rs | 1 + crates/pf-console-ui/src/screens/library.rs | 1 + crates/pf-console-ui/src/screens/pair.rs | 159 ++++++++++++++++--- crates/pf-console-ui/src/screens/settings.rs | 49 +++++- crates/pf-console-ui/src/shell.rs | 20 +++ crates/pf-presenter/src/overlay.rs | 5 + 8 files changed, 263 insertions(+), 32 deletions(-) diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index ba7354f3..b5a8cdbe 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -29,6 +29,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +/// A request-access connect awaiting the operator's approval on the host: stamped by the +/// launch handler and consumed by `on_connected`, which persists the host as paired. +struct PendingApproval { + name: String, + addr: String, + port: u16, + fp_hex: String, +} + pub fn run(target: Option<&str>) -> u8 { let identity = match trust::load_or_create_identity() { Ok(i) => i, @@ -128,6 +137,14 @@ pub fn run(target: Option<&str>) -> u8 { // `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent. let json_status = arg_flag("--json-status"); let settings_at_start = trust::Settings::load(); + + // Request-access hand-off: the launch handler stamps this when it starts a delegated-approval + // connect; `on_connected` reads it once the host lets us in and persists the host as PAIRED, + // so the next connect is an ordinary one. `None` for every normal launch, so `on_connected` + // then only touches last-used. + let pending_approval: Arc>> = Arc::new(Mutex::new(None)); + let pending_cb = pending_approval.clone(); + let opts = pf_presenter::SessionOpts { window_title: window_label.map_or_else( || "Punktfunk".to_string(), @@ -142,8 +159,16 @@ pub fn run(target: Option<&str>) -> u8 { }, touch_mode: settings_at_start.touch_mode(), json_status, - on_connected: Some(Box::new(|fingerprint: [u8; 32]| { - trust::touch_last_used(&trust::hex(&fingerprint)); + on_connected: Some(Box::new(move |fingerprint: [u8; 32]| { + let fp_hex = trust::hex(&fingerprint); + trust::touch_last_used(&fp_hex); + // A request-access connect just succeeded → the operator approved us. Save the + // host as paired (it was unsaved/discovered), keyed to the fingerprint we pinned. + if let Some(p) = pending_cb.lock().unwrap().take() { + if p.fp_hex == fp_hex { + trust::persist_host(&p.name, &p.addr, p.port, &fp_hex, true); + } + } })), overlay: Some(Box::new(overlay)), window_size: crate::session_main::window_size(&settings_at_start), @@ -161,21 +186,23 @@ pub fn run(target: Option<&str>) -> u8 { fp_hex, launch, title, + request_access, } => { let Some(pin) = trust::parse_hex32(&fp_hex) else { - // The console only offers Connect on paired rows; a pinless - // launch is a logic slip, never a silent TOFU. + // Connect (and request-access) pin the host's advertised fingerprint; + // a pinless launch is a logic slip, never a silent TOFU. tracing::warn!(%addr, "launch without a stored pin — refusing"); return ActionOutcome::Handled; }; - tracing::info!(%addr, %title, launch = launch.as_deref().unwrap_or("desktop"), + tracing::info!(%addr, %title, request_access, + launch = launch.as_deref().unwrap_or("desktop"), "launching from the console"); // Settings re-load per launch: the console's own settings screen // may have changed them since the last stream. let settings = trust::Settings::load(); - ActionOutcome::Start(Box::new(session_params( + let mut params = session_params( &settings, - addr, + addr.clone(), port, pin, identity.clone(), @@ -184,7 +211,20 @@ pub fn run(target: Option<&str>) -> u8 { native, force_software, vulkan, - ))) + ); + if request_access { + // The host PARKS the connect until the operator approves — outlast its + // approval window (host `PENDING_APPROVAL_WAIT`), matching the desktop + // shells' 185 s. On success `on_connected` persists the host as paired. + params.connect_timeout = Duration::from_secs(185); + *pending_approval.lock().unwrap() = Some(PendingApproval { + name: title.clone(), + addr, + port, + fp_hex: fp_hex.clone(), + }); + } + ActionOutcome::Start(Box::new(params)) } OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side OverlayAction::Quit => ActionOutcome::Quit, diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs index 8324dc26..7c7e3232 100644 --- a/crates/pf-console-ui/src/screens.rs +++ b/crates/pf-console-ui/src/screens.rs @@ -53,6 +53,10 @@ pub(crate) struct ConnectIntent { pub launch: Option, /// What the connecting card says (host or game title). pub title: String, + /// The no-PIN delegated-approval connect (the pair screen's "Request access"): the + /// shell shows a "waiting for approval" takeover instead of "connecting", and the + /// binary parks on a long budget and persists the host as paired once let in. + pub request_access: bool, } pub(crate) enum Nav { diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index 057445d0..e614d238 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -100,6 +100,7 @@ impl HomeScreen { fp_hex: h.fp_hex.clone(), launch: None, title: h.name.clone(), + request_access: false, }); } } diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index 6f1f7d72..83facd3a 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -119,6 +119,7 @@ impl LibraryScreen { fp_hex: self.fp_hex.clone(), launch: Some(g.id.clone()), title: g.title.clone(), + request_access: false, }); Some(MenuPulse::Confirm) } diff --git a/crates/pf-console-ui/src/screens/pair.rs b/crates/pf-console-ui/src/screens/pair.rs index c190ec40..13a9d68c 100644 --- a/crates/pf-console-ui/src/screens/pair.rs +++ b/crates/pf-console-ui/src/screens/pair.rs @@ -6,7 +6,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::{ConsoleCmd, HostRow, PairPhase}; -use crate::screens::{Ctx, Outbox}; +use crate::screens::{ConnectIntent, Ctx, Outbox}; use crate::theme::{Fonts, DIM, ERROR, W}; use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; @@ -18,10 +18,24 @@ enum Field { Device, } +/// The ordered actions a pair screen presents. `RequestAccess` leads only when the host +/// has an advertised fingerprint to pin (a discovered host); a manually-typed host with +/// no advert is PIN-only, exactly like the desktop shells. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Role { + RequestAccess, + Pin, + Device, + Pair, +} + pub(crate) struct PairScreen { host_name: String, addr: String, port: u16, + /// The host's advertised certificate fingerprint (lowercase hex); empty = a manual + /// entry with no advert → no request-access path. + fp_hex: String, list: MenuList, keyboard: Keyboard, pin: String, @@ -39,6 +53,7 @@ impl PairScreen { host_name: host.name.clone(), addr: host.addr.clone(), port: host.port, + fp_hex: host.fp_hex.clone(), list: MenuList::new(), keyboard: Keyboard::new(), pin: String::new(), @@ -49,6 +64,25 @@ impl PairScreen { } } + /// Whether the no-PIN "request access" action is offered (host advertises an identity + /// to pin). Stable across `busy` so the row list never reshuffles mid-ceremony. + fn can_request(&self) -> bool { + !self.fp_hex.is_empty() + } + + /// The ordered roles for the current host — the single source both `rows` (render) and + /// `menu` (activate dispatch) index, so a cursor never acts on a stale row. + fn roles(&self) -> Vec { + let mut roles = Vec::with_capacity(4); + if self.can_request() { + roles.push(Role::RequestAccess); + } + roles.push(Role::Pin); + roles.push(Role::Device); + roles.push(Role::Pair); + roles + } + pub(crate) fn host_name(&self) -> &str { &self.host_name } @@ -170,13 +204,29 @@ impl PairScreen { fx.pop(); return None; } - let (msg, pulse) = self.list.menu(ev, 3); + let roles = self.roles(); + let (msg, pulse) = self.list.menu(ev, roles.len()); match msg { ListMsg::Activate => { - match self.list.cursor { - 0 => self.editing = Some(Field::Pin), - 1 => self.editing = Some(Field::Device), - _ if self.can_pair() => { + match roles.get(self.list.cursor) { + Some(Role::RequestAccess) if !self.busy => { + // The no-PIN path: connect and park until the operator approves this + // device on the host. The shell shows the approval takeover; on + // success the binary persists the host as paired. Leave the pair + // screen so a canceled or finished session returns to Home. + fx.connect = Some(ConnectIntent { + addr: self.addr.clone(), + port: self.port, + fp_hex: self.fp_hex.clone(), + launch: None, + title: self.host_name.clone(), + request_access: true, + }); + fx.pop(); + } + Some(Role::Pin) => self.editing = Some(Field::Pin), + Some(Role::Device) => self.editing = Some(Field::Device), + Some(Role::Pair) if self.can_pair() => { self.busy = true; self.error = None; fx.cmds.push(ConsoleCmd::Pair { @@ -191,8 +241,11 @@ impl PairScreen { }); } _ => { - // No PIN yet — jump into the PIN field instead of a dead press. - self.list.cursor = 0; + // Pair with no PIN yet (or a request while busy) — jump into the + // PIN field instead of a dead press. + if let Some(i) = roles.iter().position(|r| *r == Role::Pin) { + self.list.cursor = i; + } self.editing = Some(Field::Pin); } } @@ -233,9 +286,14 @@ impl PairScreen { ctx: &mut Ctx, ) { let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0; + let intro = if self.can_request() { + "Request access and approve this device on the host, or enter the PIN it shows." + } else { + "Enter the PIN from the host's web console (Pairing page) or its log." + }; fonts.centered( canvas, - "Enter the PIN from the host's web console (Pairing page) or its log.", + intro, W::Regular, 13.0 * k, DIM, @@ -317,19 +375,39 @@ impl PairScreen { .collect::() .trim_end() .to_string(); - let mut pin = RowSpec::field("PIN", pin_display, "From the host"); - pin.caret = self.editing == Some(Field::Pin); - let mut device = RowSpec::field( - "Device name", - self.device.clone(), - "How the host lists this device", - ); - device.caret = self.editing == Some(Field::Device); - vec![ - pin, - device, - RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair()), - ] + let has_request = self.can_request(); + self.roles() + .into_iter() + .map(|role| match role { + Role::RequestAccess => { + let mut r = RowSpec::action("Request access — approve on the host", !self.busy); + r.header = Some("No PIN needed"); + r + } + Role::Pin => { + let mut pin = RowSpec::field("PIN", pin_display.clone(), "From the host"); + pin.caret = self.editing == Some(Field::Pin); + // When a request-access path is offered above, head the PIN group so + // the two ways to pair read as alternatives. + if has_request { + pin.header = Some("Or pair with a PIN"); + } + pin + } + Role::Device => { + let mut device = RowSpec::field( + "Device name", + self.device.clone(), + "How the host lists this device", + ); + device.caret = self.editing == Some(Field::Device); + device + } + Role::Pair => { + RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair()) + } + }) + .collect() } } @@ -388,6 +466,43 @@ mod tests { assert!(fx.cmds.is_empty()); } + /// A host with an advertised fingerprint offers Request Access as the first row; A on + /// it raises a request-access connect intent (pinning the advert) and leaves the screen. + #[test] + fn request_access_connects_and_leaves() { + let mut host = host(); + host.fp_hex = "abcd".into(); + let mut settings = Settings::default(); + let pads = Vec::new(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "deck", + t: 0.0, + }; + let mut s = PairScreen::new(&host, "deck"); + assert_eq!(s.roles().len(), 4, "Request Access + PIN + Device + Pair"); + s.list.cursor = 0; // the Request Access row leads + let mut fx = Outbox::default(); + s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + let intent = fx.connect.expect("request-access raises a connect intent"); + assert!(intent.request_access); + assert_eq!(intent.fp_hex, "abcd"); + assert!(matches!(fx.nav, Some(crate::screens::Nav::Pop))); + } + + /// A manual host (no advert) is PIN-only — the Request Access row never appears. + #[test] + fn no_request_access_without_an_advert() { + let s = PairScreen::new(&host(), "deck"); // host() has an empty fp_hex + assert!(!s.can_request()); + assert_eq!(s.roles().len(), 3, "PIN + Device + Pair only"); + } + #[test] fn pin_is_digits_only() { let mut s = PairScreen::new(&host(), "d"); diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index f2347953..fd53729d 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox}; use crate::theme::{Fonts, DIM, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; -use pf_client_core::trust::StatsVerbosity; +use pf_client_core::trust::{StatsVerbosity, TouchMode}; use skia_safe::{Canvas, Rect}; /// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale @@ -28,10 +28,11 @@ enum RowId { Mic, Pad, PadType, + Touch, Stats, } -const ROWS: [RowId; 12] = [ +const ROWS: [RowId; 13] = [ RowId::Resolution, RowId::Refresh, RowId::Bitrate, @@ -43,6 +44,7 @@ const ROWS: [RowId; 12] = [ RowId::Mic, RowId::Pad, RowId::PadType, + RowId::Touch, RowId::Stats, ]; @@ -241,6 +243,11 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { "Controller type", label_for(&PAD_TYPES, &s.gamepad).into(), ), + RowId::Touch => ( + Some("Touchscreen"), + "Touch mode", + s.touch_mode().label().into(), + ), RowId::Stats => ( Some("Interface"), "Statistics overlay", @@ -278,6 +285,10 @@ fn detail(id: RowId) -> &'static str { RowId::Mic => "Send this device's microphone to the host's virtual mic.", RowId::Pad => "Which pad is forwarded to the host, as player 1.", RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.", + RowId::Touch => { + "How the touchscreen drives the host: Trackpad (relative cursor), \ + Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)." + } RowId::Stats => { "How much the overlay shows: Compact (one line) → Normal → Detailed. \ Ctrl+Alt+Shift+S cycles it live while streaming." @@ -348,6 +359,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone()) } RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap), + RowId::Touch => { + let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode()); + step_option(cur, TouchMode::ALL.len(), delta, wrap) + .map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string()) + } RowId::Stats => { let cur = StatsVerbosity::ALL .iter() @@ -462,6 +478,35 @@ mod tests { assert!(!ctx.settings.mic_enabled); } + #[test] + fn touch_mode_steps_and_wraps() { + let (mut settings, pads) = ctx_parts(); + assert_eq!(settings.touch_mode, "trackpad"); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + // Trackpad → Pointer → Touch, then a step past the end is a boundary. + assert!( + !adjust(RowId::Touch, -1, false, &mut ctx), + "already first = thud" + ); + assert!(adjust(RowId::Touch, 1, false, &mut ctx)); + assert_eq!(ctx.settings.touch_mode, "pointer"); + assert!(adjust(RowId::Touch, 1, false, &mut ctx)); + assert_eq!(ctx.settings.touch_mode, "touch"); + assert!(!adjust(RowId::Touch, 1, false, &mut ctx), "last = thud"); + // A wraps back to the first. + assert!(adjust(RowId::Touch, 1, true, &mut ctx)); + assert_eq!(ctx.settings.touch_mode, "trackpad"); + } + #[test] fn unknown_value_snaps_to_first() { let (mut settings, pads) = ctx_parts(); diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index b417bd81..0f5a88ff 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -45,6 +45,9 @@ struct Connecting { title: String, canceling: bool, appear: f64, + /// A request-access wait (parked on the host until the operator approves) — the + /// takeover reads "Waiting for approval" rather than "Connecting". + request_access: bool, } /// What the session binary hands the shell at construction. @@ -152,6 +155,7 @@ impl Shell { title, canceling: false, appear: 0.0, + request_access: false, }) } None => self.connecting = None, @@ -236,6 +240,7 @@ impl Shell { fp_hex: h.fp_hex.clone(), launch: None, title: h.name.clone(), + request_access: false, }) }); self.bus.send(ConsoleCmd::CancelWake); @@ -254,12 +259,16 @@ impl Shell { fn start_connect(&mut self, intent: ConnectIntent) { self.set_connecting(Some(intent.title.clone())); + if let Some(c) = &mut self.connecting { + c.request_access = intent.request_access; + } self.actions.push_back(OverlayAction::Launch { addr: intent.addr, port: intent.port, fp_hex: intent.fp_hex, launch: intent.launch, title: intent.title, + request_access: intent.request_access, }); } @@ -629,6 +638,17 @@ impl Shell { String::new(), vec![], )) + } else if c.request_access { + Some(( + c.appear, + true, + "Waiting for approval…".to_string(), + format!( + "Approve this device in {}'s console or web UI — no PIN needed.", + c.title + ), + vec![Hint::new(HintKey::Back, "Cancel")], + )) } else { Some(( c.appear, diff --git a/crates/pf-presenter/src/overlay.rs b/crates/pf-presenter/src/overlay.rs index c2f6e8a3..2dbf759d 100644 --- a/crates/pf-presenter/src/overlay.rs +++ b/crates/pf-presenter/src/overlay.rs @@ -73,6 +73,11 @@ pub enum OverlayAction { fp_hex: String, launch: Option, title: String, + /// The no-PIN delegated-approval path: pin the host's advertised fingerprint and + /// open a connect the host PARKS until the operator approves this device in its + /// console (a long connect budget), then persist it as paired. `false` = an + /// ordinary connect to an already-paired host. + request_access: bool, }, /// Abort an in-flight connect (B while Connecting) — the console keeps browsing. /// The run loop stops the pump; a dial that already won the race is quit-closed.