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:
@@ -29,6 +29,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
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 {
|
pub fn run(target: Option<&str>) -> u8 {
|
||||||
let identity = match trust::load_or_create_identity() {
|
let identity = match trust::load_or_create_identity() {
|
||||||
Ok(i) => i,
|
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.
|
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
||||||
let json_status = arg_flag("--json-status");
|
let json_status = arg_flag("--json-status");
|
||||||
let settings_at_start = trust::Settings::load();
|
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 {
|
let opts = pf_presenter::SessionOpts {
|
||||||
window_title: window_label.map_or_else(
|
window_title: window_label.map_or_else(
|
||||||
|| "Punktfunk".to_string(),
|
|| "Punktfunk".to_string(),
|
||||||
@@ -142,8 +159,16 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
},
|
},
|
||||||
touch_mode: settings_at_start.touch_mode(),
|
touch_mode: settings_at_start.touch_mode(),
|
||||||
json_status,
|
json_status,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
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)),
|
overlay: Some(Box::new(overlay)),
|
||||||
window_size: crate::session_main::window_size(&settings_at_start),
|
window_size: crate::session_main::window_size(&settings_at_start),
|
||||||
@@ -161,21 +186,23 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
fp_hex,
|
fp_hex,
|
||||||
launch,
|
launch,
|
||||||
title,
|
title,
|
||||||
|
request_access,
|
||||||
} => {
|
} => {
|
||||||
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
||||||
// The console only offers Connect on paired rows; a pinless
|
// Connect (and request-access) pin the host's advertised fingerprint;
|
||||||
// launch is a logic slip, never a silent TOFU.
|
// a pinless launch is a logic slip, never a silent TOFU.
|
||||||
tracing::warn!(%addr, "launch without a stored pin — refusing");
|
tracing::warn!(%addr, "launch without a stored pin — refusing");
|
||||||
return ActionOutcome::Handled;
|
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");
|
"launching from the console");
|
||||||
// Settings re-load per launch: the console's own settings screen
|
// Settings re-load per launch: the console's own settings screen
|
||||||
// may have changed them since the last stream.
|
// may have changed them since the last stream.
|
||||||
let settings = trust::Settings::load();
|
let settings = trust::Settings::load();
|
||||||
ActionOutcome::Start(Box::new(session_params(
|
let mut params = session_params(
|
||||||
&settings,
|
&settings,
|
||||||
addr,
|
addr.clone(),
|
||||||
port,
|
port,
|
||||||
pin,
|
pin,
|
||||||
identity.clone(),
|
identity.clone(),
|
||||||
@@ -184,7 +211,20 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
native,
|
native,
|
||||||
force_software,
|
force_software,
|
||||||
vulkan,
|
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::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
||||||
OverlayAction::Quit => ActionOutcome::Quit,
|
OverlayAction::Quit => ActionOutcome::Quit,
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ pub(crate) struct ConnectIntent {
|
|||||||
pub launch: Option<String>,
|
pub launch: Option<String>,
|
||||||
/// What the connecting card says (host or game title).
|
/// What the connecting card says (host or game title).
|
||||||
pub title: String,
|
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 {
|
pub(crate) enum Nav {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ impl HomeScreen {
|
|||||||
fp_hex: h.fp_hex.clone(),
|
fp_hex: h.fp_hex.clone(),
|
||||||
launch: None,
|
launch: None,
|
||||||
title: h.name.clone(),
|
title: h.name.clone(),
|
||||||
|
request_access: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ impl LibraryScreen {
|
|||||||
fp_hex: self.fp_hex.clone(),
|
fp_hex: self.fp_hex.clone(),
|
||||||
launch: Some(g.id.clone()),
|
launch: Some(g.id.clone()),
|
||||||
title: g.title.clone(),
|
title: g.title.clone(),
|
||||||
|
request_access: false,
|
||||||
});
|
});
|
||||||
Some(MenuPulse::Confirm)
|
Some(MenuPulse::Confirm)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use crate::glyphs::{Hint, HintKey};
|
use crate::glyphs::{Hint, HintKey};
|
||||||
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
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::theme::{Fonts, DIM, ERROR, W};
|
||||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
@@ -18,10 +18,24 @@ enum Field {
|
|||||||
Device,
|
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 {
|
pub(crate) struct PairScreen {
|
||||||
host_name: String,
|
host_name: String,
|
||||||
addr: String,
|
addr: String,
|
||||||
port: u16,
|
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,
|
list: MenuList,
|
||||||
keyboard: Keyboard,
|
keyboard: Keyboard,
|
||||||
pin: String,
|
pin: String,
|
||||||
@@ -39,6 +53,7 @@ impl PairScreen {
|
|||||||
host_name: host.name.clone(),
|
host_name: host.name.clone(),
|
||||||
addr: host.addr.clone(),
|
addr: host.addr.clone(),
|
||||||
port: host.port,
|
port: host.port,
|
||||||
|
fp_hex: host.fp_hex.clone(),
|
||||||
list: MenuList::new(),
|
list: MenuList::new(),
|
||||||
keyboard: Keyboard::new(),
|
keyboard: Keyboard::new(),
|
||||||
pin: String::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 {
|
pub(crate) fn host_name(&self) -> &str {
|
||||||
&self.host_name
|
&self.host_name
|
||||||
}
|
}
|
||||||
@@ -170,13 +204,29 @@ impl PairScreen {
|
|||||||
fx.pop();
|
fx.pop();
|
||||||
return None;
|
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 {
|
match msg {
|
||||||
ListMsg::Activate => {
|
ListMsg::Activate => {
|
||||||
match self.list.cursor {
|
match roles.get(self.list.cursor) {
|
||||||
0 => self.editing = Some(Field::Pin),
|
Some(Role::RequestAccess) if !self.busy => {
|
||||||
1 => self.editing = Some(Field::Device),
|
// The no-PIN path: connect and park until the operator approves this
|
||||||
_ if self.can_pair() => {
|
// 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.busy = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
fx.cmds.push(ConsoleCmd::Pair {
|
fx.cmds.push(ConsoleCmd::Pair {
|
||||||
@@ -191,8 +241,11 @@ impl PairScreen {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// No PIN yet — jump into the PIN field instead of a dead press.
|
// Pair with no PIN yet (or a request while busy) — jump into the
|
||||||
self.list.cursor = 0;
|
// 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);
|
self.editing = Some(Field::Pin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,9 +286,14 @@ impl PairScreen {
|
|||||||
ctx: &mut Ctx,
|
ctx: &mut Ctx,
|
||||||
) {
|
) {
|
||||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
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(
|
fonts.centered(
|
||||||
canvas,
|
canvas,
|
||||||
"Enter the PIN from the host's web console (Pairing page) or its log.",
|
intro,
|
||||||
W::Regular,
|
W::Regular,
|
||||||
13.0 * k,
|
13.0 * k,
|
||||||
DIM,
|
DIM,
|
||||||
@@ -317,19 +375,39 @@ impl PairScreen {
|
|||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
.trim_end()
|
.trim_end()
|
||||||
.to_string();
|
.to_string();
|
||||||
let mut pin = RowSpec::field("PIN", pin_display, "From the host");
|
let has_request = self.can_request();
|
||||||
pin.caret = self.editing == Some(Field::Pin);
|
self.roles()
|
||||||
let mut device = RowSpec::field(
|
.into_iter()
|
||||||
"Device name",
|
.map(|role| match role {
|
||||||
self.device.clone(),
|
Role::RequestAccess => {
|
||||||
"How the host lists this device",
|
let mut r = RowSpec::action("Request access — approve on the host", !self.busy);
|
||||||
);
|
r.header = Some("No PIN needed");
|
||||||
device.caret = self.editing == Some(Field::Device);
|
r
|
||||||
vec![
|
}
|
||||||
pin,
|
Role::Pin => {
|
||||||
device,
|
let mut pin = RowSpec::field("PIN", pin_display.clone(), "From the host");
|
||||||
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair()),
|
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());
|
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]
|
#[test]
|
||||||
fn pin_is_digits_only() {
|
fn pin_is_digits_only() {
|
||||||
let mut s = PairScreen::new(&host(), "d");
|
let mut s = PairScreen::new(&host(), "d");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
|||||||
use crate::theme::{Fonts, DIM, W};
|
use crate::theme::{Fonts, DIM, W};
|
||||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
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};
|
use skia_safe::{Canvas, Rect};
|
||||||
|
|
||||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||||
@@ -28,10 +28,11 @@ enum RowId {
|
|||||||
Mic,
|
Mic,
|
||||||
Pad,
|
Pad,
|
||||||
PadType,
|
PadType,
|
||||||
|
Touch,
|
||||||
Stats,
|
Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROWS: [RowId; 12] = [
|
const ROWS: [RowId; 13] = [
|
||||||
RowId::Resolution,
|
RowId::Resolution,
|
||||||
RowId::Refresh,
|
RowId::Refresh,
|
||||||
RowId::Bitrate,
|
RowId::Bitrate,
|
||||||
@@ -43,6 +44,7 @@ const ROWS: [RowId; 12] = [
|
|||||||
RowId::Mic,
|
RowId::Mic,
|
||||||
RowId::Pad,
|
RowId::Pad,
|
||||||
RowId::PadType,
|
RowId::PadType,
|
||||||
|
RowId::Touch,
|
||||||
RowId::Stats,
|
RowId::Stats,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -241,6 +243,11 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
"Controller type",
|
"Controller type",
|
||||||
label_for(&PAD_TYPES, &s.gamepad).into(),
|
label_for(&PAD_TYPES, &s.gamepad).into(),
|
||||||
),
|
),
|
||||||
|
RowId::Touch => (
|
||||||
|
Some("Touchscreen"),
|
||||||
|
"Touch mode",
|
||||||
|
s.touch_mode().label().into(),
|
||||||
|
),
|
||||||
RowId::Stats => (
|
RowId::Stats => (
|
||||||
Some("Interface"),
|
Some("Interface"),
|
||||||
"Statistics overlay",
|
"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::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::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
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 => {
|
RowId::Stats => {
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
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())
|
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::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 => {
|
RowId::Stats => {
|
||||||
let cur = StatsVerbosity::ALL
|
let cur = StatsVerbosity::ALL
|
||||||
.iter()
|
.iter()
|
||||||
@@ -462,6 +478,35 @@ mod tests {
|
|||||||
assert!(!ctx.settings.mic_enabled);
|
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]
|
#[test]
|
||||||
fn unknown_value_snaps_to_first() {
|
fn unknown_value_snaps_to_first() {
|
||||||
let (mut settings, pads) = ctx_parts();
|
let (mut settings, pads) = ctx_parts();
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ struct Connecting {
|
|||||||
title: String,
|
title: String,
|
||||||
canceling: bool,
|
canceling: bool,
|
||||||
appear: f64,
|
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.
|
/// What the session binary hands the shell at construction.
|
||||||
@@ -152,6 +155,7 @@ impl Shell {
|
|||||||
title,
|
title,
|
||||||
canceling: false,
|
canceling: false,
|
||||||
appear: 0.0,
|
appear: 0.0,
|
||||||
|
request_access: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => self.connecting = None,
|
None => self.connecting = None,
|
||||||
@@ -236,6 +240,7 @@ impl Shell {
|
|||||||
fp_hex: h.fp_hex.clone(),
|
fp_hex: h.fp_hex.clone(),
|
||||||
launch: None,
|
launch: None,
|
||||||
title: h.name.clone(),
|
title: h.name.clone(),
|
||||||
|
request_access: false,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
self.bus.send(ConsoleCmd::CancelWake);
|
self.bus.send(ConsoleCmd::CancelWake);
|
||||||
@@ -254,12 +259,16 @@ impl Shell {
|
|||||||
|
|
||||||
fn start_connect(&mut self, intent: ConnectIntent) {
|
fn start_connect(&mut self, intent: ConnectIntent) {
|
||||||
self.set_connecting(Some(intent.title.clone()));
|
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 {
|
self.actions.push_back(OverlayAction::Launch {
|
||||||
addr: intent.addr,
|
addr: intent.addr,
|
||||||
port: intent.port,
|
port: intent.port,
|
||||||
fp_hex: intent.fp_hex,
|
fp_hex: intent.fp_hex,
|
||||||
launch: intent.launch,
|
launch: intent.launch,
|
||||||
title: intent.title,
|
title: intent.title,
|
||||||
|
request_access: intent.request_access,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +638,17 @@ impl Shell {
|
|||||||
String::new(),
|
String::new(),
|
||||||
vec![],
|
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 {
|
} else {
|
||||||
Some((
|
Some((
|
||||||
c.appear,
|
c.appear,
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ pub enum OverlayAction {
|
|||||||
fp_hex: String,
|
fp_hex: String,
|
||||||
launch: Option<String>,
|
launch: Option<String>,
|
||||||
title: 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.
|
/// 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.
|
/// The run loop stops the pump; a dial that already won the race is quit-closed.
|
||||||
|
|||||||
Reference in New Issue
Block a user