feat(console): touch-mode setting + request-access pairing + polish

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 16:31:25 +02:00
parent f3b6ccaa7f
commit 927a571414
8 changed files with 263 additions and 32 deletions
+48 -8
View File
@@ -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<Mutex<Option<PendingApproval>>> = 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,
+4
View File
@@ -53,6 +53,10 @@ pub(crate) struct ConnectIntent {
pub launch: Option<String>,
/// 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 {
+1
View File
@@ -100,6 +100,7 @@ impl HomeScreen {
fp_hex: h.fp_hex.clone(),
launch: None,
title: h.name.clone(),
request_access: false,
});
}
}
@@ -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)
}
+137 -22
View File
@@ -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<Role> {
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::<String>()
.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");
+47 -2
View File
@@ -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();
+20
View File
@@ -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,
+5
View File
@@ -73,6 +73,11 @@ pub enum OverlayAction {
fp_hex: String,
launch: Option<String>,
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.