Files
punktfunk/crates/pf-console-ui/src/screens/pair.rs
T
enricobuehlerandClaude Fable 5 927a571414 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>
2026-07-14 16:31:25 +02:00

523 lines
18 KiB
Rust

//! The in-console PIN pairing screen — the couch counterpart of the desktop PairSheet,
//! and the piece that makes a Steam Deck self-sufficient (pairing used to need the
//! Decky plugin or a desktop). The host shows a PIN in its web console; the user types
//! it here (on-screen keyboard, or Steam's keyboard on Deck), the SPAKE2 ceremony runs
//! on the binary's service thread, and success pins the host and pops back to Home.
use crate::glyphs::{Hint, HintKey};
use crate::model::{ConsoleCmd, HostRow, PairPhase};
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};
use skia_safe::{Canvas, Rect};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Field {
Pin,
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,
device: String,
editing: Option<Field>,
/// The ceremony is in flight (mirrors the model's `PairPhase::Busy` immediately so
/// a second A can't double-submit before the service thread picks the command up).
busy: bool,
error: Option<String>,
}
impl PairScreen {
pub(crate) fn new(host: &HostRow, device_name: &str) -> PairScreen {
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(),
device: device_name.to_string(),
editing: None,
busy: false,
error: None,
}
}
/// 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
}
/// The shell routes the model's pairing phase here (success pops shell-side).
pub(crate) fn apply_phase(&mut self, phase: &PairPhase) {
match phase {
PairPhase::Busy => self.busy = true,
PairPhase::Failed(msg) => {
self.busy = false;
self.error = Some(msg.clone());
}
PairPhase::Idle | PairPhase::Paired { .. } => self.busy = false,
}
}
pub(crate) fn editing(&self) -> bool {
self.editing.is_some()
}
fn can_pair(&self) -> bool {
!self.pin.trim().is_empty() && !self.busy
}
fn field_mut(&mut self, f: Field) -> &mut String {
match f {
Field::Pin => &mut self.pin,
Field::Device => &mut self.device,
}
}
fn charset(f: Field) -> Charset {
match f {
Field::Pin => Charset::Digits,
Field::Device => Charset::Free,
}
}
fn type_char(&mut self, ch: char) -> bool {
let Some(f) = self.editing else { return false };
if !permits(Self::charset(f), ch) {
return false;
}
if f == Field::Pin && self.pin.chars().count() >= 8 {
return false; // PINs are 4 digits today; leave headroom, refuse novels
}
self.field_mut(f).push(ch);
true
}
pub(crate) fn text_input(&mut self, text: &str) {
for ch in text.chars() {
self.type_char(ch);
}
}
pub(crate) fn edit_key(&mut self, sc: sdl3::keyboard::Scancode) -> bool {
use sdl3::keyboard::Scancode as S;
if self.editing.is_none() {
return false;
}
match sc {
S::Backspace => {
let f = self.editing.unwrap();
self.field_mut(f).pop();
true
}
S::Return | S::KpEnter | S::Escape => {
self.editing = None;
true
}
_ => false,
}
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if self.editing.is_some() {
if ctx.deck {
return match ev {
MenuEvent::Back | MenuEvent::Confirm => {
self.editing = None;
Some(MenuPulse::Confirm)
}
_ => None,
};
}
let (msg, pulse) = self.keyboard.menu(ev);
return match msg {
KeyMsg::Type(c) => {
if self.type_char(c) {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Backspace => {
let f = self.editing.unwrap();
if self.field_mut(f).pop().is_some() {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Done => {
self.editing = None;
Some(MenuPulse::Confirm)
}
KeyMsg::None => pulse,
};
}
if ev == MenuEvent::Back {
// Leaving mid-ceremony is fine — success still pins and toasts globally.
fx.pop();
return None;
}
let roles = self.roles();
let (msg, pulse) = self.list.menu(ev, roles.len());
match msg {
ListMsg::Activate => {
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 {
addr: self.addr.clone(),
port: self.port,
pin: self.pin.trim().to_string(),
device_name: if self.device.trim().is_empty() {
ctx.device_name.to_string()
} else {
self.device.trim().to_string()
},
});
}
_ => {
// 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);
}
}
pulse
}
_ => pulse,
}
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
if self.editing.is_some() {
if ctx.deck {
return vec![
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
Hint::new(HintKey::Confirm, "Done"),
Hint::new(HintKey::Back, "Done"),
];
}
return vec![
Hint::new(HintKey::Confirm, "Type"),
Hint::new(HintKey::Tertiary, "Delete"),
Hint::new(HintKey::Back, "Done"),
];
}
vec![
Hint::new(HintKey::Confirm, "Select"),
Hint::new(HintKey::Back, "Cancel"),
]
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
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,
intro,
W::Regular,
13.0 * k,
DIM,
cx,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
);
let seat = self.keyboard.seat(self.editing.is_some() && !ctx.deck, dt);
let tray_h = if seat > 0.0 {
(Keyboard::tray_height() + 12.0) * k * seat
} else {
0.0
};
// A status band under the rows: the ceremony spinner or the error line.
let status_h = 34.0 * k;
let list_rect = Rect::from_ltrb(
rect.left,
rect.top + (34.0 * k) as f32,
rect.right,
rect.bottom - tray_h as f32 - status_h as f32,
);
let rows = self.rows();
self.list.render(
canvas,
list_rect,
&rows,
fonts,
k,
dt,
self.editing.is_none() && !self.busy,
);
let status_y = f64::from(rect.bottom) - tray_h - status_h + 6.0 * k;
if self.busy {
crate::theme::spinner(canvas, cx - 70.0 * k, status_y + 8.0 * k, 7.0 * k, ctx.t);
fonts.centered(
canvas,
"Pairing… confirm the PIN on the host",
W::Regular,
13.0 * k,
DIM,
cx + 10.0 * k,
status_y,
f64::from(rect.width()) * 0.6,
);
} else if let Some(err) = &self.error {
fonts.centered(
canvas,
err,
W::Regular,
13.0 * k,
ERROR,
cx,
status_y,
f64::from(rect.width()) * 0.8,
);
}
if seat > 0.0 {
self.keyboard.render(
canvas,
fonts,
f64::from(rect.width()),
f64::from(rect.bottom),
seat,
k,
);
}
}
fn rows(&self) -> Vec<RowSpec> {
// The PIN renders spaced out (● would hide typos; a PIN is not a secret worth
// masking — the host shows it on screen anyway).
let pin_display: String = self
.pin
.chars()
.flat_map(|c| [c, ' '])
.collect::<String>()
.trim_end()
.to_string();
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
use pf_client_core::trust::Settings;
fn host() -> HostRow {
HostRow {
key: "10.0.0.7:9777".into(),
name: "Tower".into(),
addr: "10.0.0.7".into(),
port: 9777,
fp_hex: String::new(),
paired: false,
saved: true,
online: true,
mgmt_port: 47990,
can_wake: false,
last_used: None,
}
}
#[test]
fn pair_submits_with_pin_and_device_fallback() {
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: "living-room-deck",
t: 0.0,
};
let mut s = PairScreen::new(&host(), "living-room-deck");
s.device.clear(); // the user deleted the prefill
s.editing = Some(Field::Pin);
s.text_input("1234");
s.edit_key(sdl3::keyboard::Scancode::Return);
s.list.cursor = 2;
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(matches!(
fx.cmds.first(),
Some(ConsoleCmd::Pair { pin, device_name, .. })
if pin == "1234" && device_name == "living-room-deck"
));
assert!(s.busy);
// A second A while busy is a no-op (the action row is disabled).
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
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");
s.editing = Some(Field::Pin);
s.text_input("12ab34");
assert_eq!(s.pin, "1234");
}
#[test]
fn failure_lands_in_the_error_line() {
let mut s = PairScreen::new(&host(), "d");
s.busy = true;
s.apply_phase(&PairPhase::Failed("Wrong PIN".into()));
assert!(!s.busy);
assert_eq!(s.error.as_deref(), Some("Wrong PIN"));
}
}