Two gaps, both found on the shared Linux/Windows console UI. **The settings tabs only moved for a gamepad.** They were bound to the shoulder buttons and to PgUp/PgDn, and the legend spells PgUp/PgDn out only when NO pad is attached — so with a controller plugged in a keyboard user had nothing to find, and a mouse or a touchscreen could not change section at all. The root cause was wider than the strip: `SkiaOverlay::handle_event` matched only `KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell past the console into the run loop, which routes pointer input exclusively at `stream.capture` — `None` while you are browsing. Nothing in the console had ever been clickable. Making just the pills answer would not have helped either: the settings screen is opened with X from home, so a mouse could not reach it. So the console gets a real pointer path: - `Overlay::handle_pointer` carries mouse/touch in SWAPCHAIN PIXELS. The run loop converts (it owns the window, hence the display scale, and mouse coordinates are logical while fingers are normalised); the console then hit-tests the very rects it drew last frame. Only DIRECT touch devices are offered — an indirect trackpad already drives the mouse. - Widgets act on the PRESS, not the release. The list and both carousels scroll the focused item toward the centre, so what you pressed has slid out from under your finger by the time it lifts; press-to-act has no such race and there is no drag gesture to compete with. - The hint bar became the pointer's button bar. It is already the console's only on-screen statement of what the face buttons do, and a pointer has none — so its Confirm/Back/Secondary/Tertiary pills are clickable on every screen, which is what puts Settings and Library within reach of a mouse at all. - Tab / Shift+Tab change section; PgUp/PgDn still do, and the keyboard legend now reads "Tab". - Right-click is Back everywhere, EXCEPT at the root: B there quits the launcher and a right-click is far easier to fire by accident. Quitting stays explicit. **Host cards had no menu.** Every other client hangs Wake / Copy link / Edit / Forget off a host card; the console could add a host and connect to one, and that was all — so a renamed machine or a fat-fingered address stayed wrong forever unless you opened a desktop shell. UP on a saved tile now opens that host's menu, the same gesture the Android console uses, on the one direction a horizontal carousel leaves free. - `ConsoleCmd::UpdateHost` edits the stored host IN PLACE. Removing and re-adding would silently drop the fingerprint, the learned MAC, the pinned cards and the profile binding — that is a rename, not a re-pair. - `ConsoleCmd::ForgetHost` drops it; if it is still advertising it returns as a discovered, unpaired row, which is the honest state. - Forget arms on the first press and fires on the second. The other clients forget outright; a console is driven by a thumbstick from across a room. - A pinned profile card offers only Unpin. It is a shortcut, not a second host, and offering to forget the host from it would blur exactly the distinction a pin draws. - "Edit…" REPLACES the menu on the stack rather than stacking over it, so Back from the editor doesn't land on a menu describing the host as it was before the edit. Verified in the pf-lxcheck2 container (this crate compiles to nothing on macOS — a bare `cargo check` there is vacuous): plain build and `clippy --all-targets` clean under `-D warnings`, 72 tests pass. Seven are new, and cover the reported bug directly — a press on a pill selects that tab, and each tab still keeps its own cursor when a pointer is what switched it.
251 lines
8.9 KiB
Rust
251 lines
8.9 KiB
Rust
//! The console's shared binary↔overlay state and command bus — the widened sibling of
|
|
//! [`crate::library::LibraryShared`]. The session binary's service threads (discovery,
|
|
//! probing, pairing, waking, persistence) WRITE snapshots in; the shell reads them per
|
|
//! frame by generation stamp. The overlay never blocks: anything that touches the
|
|
//! network or disk rides a [`ConsoleCmd`] to the binary instead.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
/// A settings profile as the console shows it (design client-settings-profiles.md §5.2a):
|
|
/// the resolved name and accent of a catalog entry, keyed by its stable id. The service
|
|
/// thread resolves these against the catalog; the shell never opens the profiles file.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ProfileChip {
|
|
pub id: String,
|
|
pub name: String,
|
|
/// `#RRGGBB`, the catalog's optional tint for pinned cards.
|
|
pub accent: Option<String>,
|
|
}
|
|
|
|
/// One row on the console home carousel — a saved host, a discovered-but-unsaved one,
|
|
/// a pinned profile card, or (client-side) the trailing Add Host tile. Fully resolved by
|
|
/// the service thread; the shell renders it verbatim.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct HostRow {
|
|
/// Stable identity across refreshes: the pinned fingerprint when known, else
|
|
/// `addr:port` — keeps the cursor on "the same host" as snapshots churn.
|
|
pub key: String,
|
|
pub name: String,
|
|
pub addr: String,
|
|
pub port: u16,
|
|
/// Pinned certificate fingerprint (lowercase hex); empty = not pinned.
|
|
pub fp_hex: String,
|
|
pub paired: bool,
|
|
/// In the known-hosts store (vs. discovered-only).
|
|
pub saved: bool,
|
|
/// Advertising on mDNS or proven reachable by the probe sweep.
|
|
pub online: bool,
|
|
/// The management API's port (mDNS TXT or store), for the library fetch.
|
|
pub mgmt_port: u16,
|
|
/// Offline + a stored MAC → activating wakes first ("Wake & Connect").
|
|
pub can_wake: bool,
|
|
/// Last successful connect (UNIX seconds) — the most-recent accent.
|
|
pub last_used: Option<u64>,
|
|
/// The host's OS-identity chain (live advert preferred, else the stored one), for a
|
|
/// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a
|
|
/// follow-up — the Skia glyph set doesn't exist yet.
|
|
pub os: String,
|
|
/// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right
|
|
/// after its host's primary tile, sharing its live state, that connects with THIS
|
|
/// profile. `None` = the host's primary tile.
|
|
pub pin: Option<ProfileChip>,
|
|
/// The primary tile's default-profile chip: the profile bound as this host's default
|
|
/// (`KnownHost::profile_id`), resolved, so the tile can say what a plain A-press uses.
|
|
/// Always `None` on pinned rows — there the profile IS `pin`.
|
|
pub bound_profile: Option<ProfileChip>,
|
|
}
|
|
|
|
/// The pairing ceremony's observable state (one at a time — the ceremony is modal).
|
|
#[derive(Clone, Debug, PartialEq, Default)]
|
|
pub enum PairPhase {
|
|
#[default]
|
|
Idle,
|
|
/// The SPAKE2 exchange is running (up to ~90 s on a mistyped-then-fixed PIN).
|
|
Busy,
|
|
Failed(String),
|
|
/// Paired and persisted; `key` addresses the host's refreshed row.
|
|
Paired {
|
|
key: String,
|
|
},
|
|
}
|
|
|
|
/// A wake-and-wait in progress (one at a time). The service thread re-sends magic
|
|
/// packets and probes; the shell renders the card and acts on `online`.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct WakeStatus {
|
|
pub key: String,
|
|
pub name: String,
|
|
/// Seconds since the wake started (the card's counter).
|
|
pub seconds: u32,
|
|
pub timed_out: bool,
|
|
/// The host answered a probe — the shell launches if the wake wanted a connect.
|
|
pub online: bool,
|
|
/// Connect once awake (A on an offline host) vs. a bare wake.
|
|
pub then_connect: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ConsoleState {
|
|
hosts: Vec<HostRow>,
|
|
hosts_gen: u64,
|
|
pair: PairPhase,
|
|
wake: Option<WakeStatus>,
|
|
}
|
|
|
|
/// The shared handle. Service threads write; the shell polls per frame (cheap locks,
|
|
/// no rendering data inside).
|
|
#[derive(Clone, Default)]
|
|
pub struct ConsoleShared(Arc<Mutex<ConsoleState>>);
|
|
|
|
impl ConsoleShared {
|
|
pub fn set_hosts(&self, hosts: Vec<HostRow>) {
|
|
let mut s = self.0.lock().unwrap();
|
|
if s.hosts != hosts {
|
|
s.hosts = hosts;
|
|
s.hosts_gen += 1;
|
|
}
|
|
}
|
|
|
|
pub(crate) fn hosts_gen(&self) -> u64 {
|
|
self.0.lock().unwrap().hosts_gen
|
|
}
|
|
|
|
pub(crate) fn hosts_snapshot(&self) -> (Vec<HostRow>, u64) {
|
|
let s = self.0.lock().unwrap();
|
|
(s.hosts.clone(), s.hosts_gen)
|
|
}
|
|
|
|
pub fn set_pair(&self, phase: PairPhase) {
|
|
self.0.lock().unwrap().pair = phase;
|
|
}
|
|
|
|
pub(crate) fn pair(&self) -> PairPhase {
|
|
self.0.lock().unwrap().pair.clone()
|
|
}
|
|
|
|
pub fn set_wake(&self, wake: Option<WakeStatus>) {
|
|
self.0.lock().unwrap().wake = wake;
|
|
}
|
|
|
|
pub(crate) fn wake(&self) -> Option<WakeStatus> {
|
|
self.0.lock().unwrap().wake.clone()
|
|
}
|
|
}
|
|
|
|
/// Work the shell asks the binary to do. Everything here blocks (network/disk), so it
|
|
/// runs on the binary's service thread, never on the render path.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ConsoleCmd {
|
|
/// (Re)fetch a host's game library into the shared library model.
|
|
FetchLibrary {
|
|
addr: String,
|
|
mgmt: u16,
|
|
fp_hex: String,
|
|
},
|
|
/// Run the SPAKE2 PIN ceremony; on success persist the pin and refresh hosts.
|
|
Pair {
|
|
addr: String,
|
|
port: u16,
|
|
pin: String,
|
|
device_name: String,
|
|
},
|
|
/// Save a manually entered host (unpaired) and refresh the rows.
|
|
SaveHost {
|
|
name: String,
|
|
addr: String,
|
|
port: u16,
|
|
},
|
|
/// Rename / re-address a saved host (the host menu's "Edit…"). `key` addresses the
|
|
/// row; the fingerprint, pins and MACs already stored against it are kept — this edits
|
|
/// a host, it doesn't replace one.
|
|
UpdateHost {
|
|
key: String,
|
|
name: String,
|
|
addr: String,
|
|
port: u16,
|
|
},
|
|
/// Drop a saved host (the host menu's "Forget"). The next connect to that address
|
|
/// starts from scratch: no pin, no pairing, no pinned cards.
|
|
ForgetHost { key: String },
|
|
/// Start the wake-and-wait loop for this saved host.
|
|
Wake { key: String, then_connect: bool },
|
|
/// Stop the wake loop (B on the wake card) and clear its status.
|
|
CancelWake,
|
|
/// Sweep reachability now (the home screen refreshes its presence pips).
|
|
Probe,
|
|
/// Pin (or unpin) a profile as an extra connect card on a saved host
|
|
/// (`KnownHost::pinned_profiles`, design §5.2a). `key` is the HOST row's key
|
|
/// (fingerprint or `addr:port`); presentation only — never touches the host's
|
|
/// default binding or the profile itself. Idempotent: re-pinning a pinned profile
|
|
/// (or unpinning an absent one) is a no-op.
|
|
SetPin {
|
|
key: String,
|
|
profile_id: String,
|
|
pin: bool,
|
|
},
|
|
}
|
|
|
|
/// The overlay→binary command queue. A plain deque under the same locking discipline as
|
|
/// the shared models — the service thread drains it on a short cadence (it's never
|
|
/// latency-critical: every command's effect arrives via a model snapshot anyway).
|
|
#[derive(Clone, Default)]
|
|
pub struct ConsoleBus(Arc<Mutex<VecDeque<ConsoleCmd>>>);
|
|
|
|
impl ConsoleBus {
|
|
/// Queue a command. Normally the shell's side; the binary may also seed one (the
|
|
/// direct-entry library fetch) — same lane, same handler.
|
|
pub fn send(&self, cmd: ConsoleCmd) {
|
|
self.0.lock().unwrap().push_back(cmd);
|
|
}
|
|
|
|
/// Binary side: drain everything queued since the last call.
|
|
pub fn drain(&self) -> Vec<ConsoleCmd> {
|
|
self.0.lock().unwrap().drain(..).collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn hosts_generation_bumps_only_on_change() {
|
|
let shared = ConsoleShared::default();
|
|
let row = HostRow {
|
|
key: "aa".into(),
|
|
name: "Tower".into(),
|
|
addr: "10.0.0.2".into(),
|
|
port: 9777,
|
|
fp_hex: "aa".into(),
|
|
paired: true,
|
|
saved: true,
|
|
online: false,
|
|
mgmt_port: 47990,
|
|
can_wake: false,
|
|
last_used: None,
|
|
os: String::new(),
|
|
pin: None,
|
|
bound_profile: None,
|
|
};
|
|
shared.set_hosts(vec![row.clone()]);
|
|
let g1 = shared.hosts_gen();
|
|
shared.set_hosts(vec![row.clone()]);
|
|
assert_eq!(shared.hosts_gen(), g1, "identical snapshot doesn't churn");
|
|
shared.set_hosts(vec![HostRow {
|
|
online: true,
|
|
..row
|
|
}]);
|
|
assert_eq!(shared.hosts_gen(), g1 + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn bus_drains_in_order() {
|
|
let bus = ConsoleBus::default();
|
|
bus.send(ConsoleCmd::Probe);
|
|
bus.send(ConsoleCmd::CancelWake);
|
|
assert_eq!(bus.drain(), vec![ConsoleCmd::Probe, ConsoleCmd::CancelWake]);
|
|
assert!(bus.drain().is_empty());
|
|
}
|
|
}
|