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
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>
408 lines
12 KiB
Rust
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"));
|
|
}
|
|
}
|