Files
punktfunk/crates/pf-console-ui/src/screens/pair.rs
T
enricobuehler 180ac3aa61
apple / swift (push) Successful in 1m6s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
decky / build-publish (push) Successful in 25s
flatpak / build-publish (push) Successful in 4m31s
feat(console): full gamepad shell — hosts, PIN pairing, settings, OSK, screen transitions
The Skia console UI grows from the single library coverflow into a complete
couch shell (Apple gamepad-UI parity), so a Deck/gamescope session is
self-sufficient end to end:

- Home: host carousel (saved + discovered + Add Host tile) with presence
  pips, paired locks, wake & connect; A/Y/X/B per the Apple launcher.
- In-console SPAKE2 PIN pairing (previously needed the Decky plugin or a
  desktop), add-host with a controller keyboard, couch settings over the
  shared Settings store, wake-on-LAN overlay with retry, cancelable dials.
- Shell chrome: screen stack with push/pop entrance/exit choreography
  (slide + fade + recede; aurora↔form backdrop crossfade), per-pad button
  glyphs (PS shapes vs ABXY, keycaps with no pad), menu haptics, toasts,
  embedded Geist typography, in-stream start banner.
- Steam Deck: our OSK never draws — fields start SDL text input (the new
  Overlay::text_input_active hook) and Steam's keyboard types; a hint chip
  points at STEAM + X. Other Linux gets the full gamepad keyboard tray.
- punktfunk-session: bare --browse opens Home; --browse host opens that
  host's library with Home behind it (Decky launches keep working, B now
  pops instead of quitting). Service threads run discovery, 10 s probes,
  pairing, wake loops, fetches, and known-hosts persistence.
- Presenter contract: Launch actions carry the host, CancelConnect never
  engages a won race, pad kind/list ride FrameCtx, menu events flow while
  dialing so B can cancel.

Every screen renders to PNG on CPU raster for review
(PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui -- --ignored dump).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:46:49 +02:00

408 lines
12 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::{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,
}
pub(crate) struct PairScreen {
host_name: String,
addr: String,
port: u16,
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,
list: MenuList::new(),
keyboard: Keyboard::new(),
pin: String::new(),
device: device_name.to_string(),
editing: None,
busy: false,
error: None,
}
}
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 (msg, pulse) = self.list.menu(ev, 3);
match msg {
ListMsg::Activate => {
match self.list.cursor {
0 => self.editing = Some(Field::Pin),
1 => self.editing = Some(Field::Device),
_ 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()
},
});
}
_ => {
// No PIN yet — jump into the PIN field instead of a dead press.
self.list.cursor = 0;
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;
fonts.centered(
canvas,
"Enter the PIN from the host's web console (Pairing page) or its log.",
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 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()),
]
}
}
#[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());
}
#[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"));
}
}