forked from unom/punktfunk
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.
907 lines
38 KiB
Rust
907 lines
38 KiB
Rust
//! `--browse [host[:port]]` — the console shell. Bare `--browse` opens the host list
|
|
//! (discovery, pairing, settings, wake — the whole couch flow); with a target it opens
|
|
//! straight into that host's library (the Decky per-host launch), B backing out to the
|
|
//! list. A launches in the SAME window (no gamescope window handoff — the whole point
|
|
//! of one process), the session's end returns to the console, B at the root quits to
|
|
//! Gaming Mode.
|
|
//!
|
|
//! This file is the console's SERVICE side: the shell (pf-console-ui) renders and
|
|
//! raises [`ConsoleCmd`]s; worker threads here run everything that blocks — mDNS
|
|
//! discovery, reachability probes, the SPAKE2 pairing ceremony, wake-on-LAN loops,
|
|
//! library fetches, known-hosts persistence — and write results into the shared
|
|
//! models. `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds canned entries with no host
|
|
//! (portrait paths starting with `/` load from disk), the GPU-only dev path.
|
|
|
|
use crate::session_main::{
|
|
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos,
|
|
};
|
|
use pf_client_core::gamepad::is_steam_deck;
|
|
use pf_client_core::{discovery, library, trust, wol};
|
|
use pf_console_ui::{
|
|
ConsoleCmd, ConsoleEntry, ConsoleHandles, ConsoleOptions, ConsoleShared, HostRow, LibraryGame,
|
|
LibraryPhase, LibraryShared, PairPhase, SkiaOverlay, WakeStatus,
|
|
};
|
|
use pf_presenter::overlay::OverlayAction;
|
|
use pf_presenter::ActionOutcome;
|
|
use std::collections::{HashMap, VecDeque};
|
|
use std::net::Ipv4Addr;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
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 {
|
|
let identity = match trust::load_or_create_identity() {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
eprintln!("client identity: {e:#}");
|
|
return crate::session_main::EXIT_CONNECT_FAILED;
|
|
}
|
|
};
|
|
|
|
// Resolve the entry point: a paired target opens straight into its library; an
|
|
// unpaired/unknown one lands on Home with the target seeded into the list (one A
|
|
// from pairing). The fake-library hook fabricates a paired host with no network.
|
|
let fake = std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some();
|
|
let known = trust::KnownHosts::load();
|
|
let mut seed: Option<HostRow> = None;
|
|
let (entry, window_label) = match target {
|
|
Some(target) => {
|
|
let (addr, port) = parse_host_port(target);
|
|
let k = known
|
|
.hosts
|
|
.iter()
|
|
.find(|h| h.addr == addr && h.port == port);
|
|
let row = HostRow {
|
|
key: k
|
|
.filter(|h| !h.fp_hex.is_empty())
|
|
.map_or_else(|| format!("{addr}:{port}"), |h| h.fp_hex.clone()),
|
|
name: k
|
|
.map(|h| host_display_name(&h.name, &h.addr))
|
|
.unwrap_or_else(|| addr.clone()),
|
|
addr: addr.clone(),
|
|
port,
|
|
fp_hex: k.map(|h| h.fp_hex.clone()).unwrap_or_default(),
|
|
paired: k.is_some_and(|h| h.paired) || fake,
|
|
saved: k.is_some(),
|
|
online: false,
|
|
mgmt_port: arg_value("--mgmt")
|
|
.and_then(|p| p.parse().ok())
|
|
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
|
can_wake: false,
|
|
last_used: k.and_then(|h| h.last_used),
|
|
os: k.map(|h| h.os.clone()).unwrap_or_default(),
|
|
pin: None,
|
|
bound_profile: None,
|
|
};
|
|
let label = row.name.clone();
|
|
if k.is_none() {
|
|
seed = Some(row.clone());
|
|
}
|
|
if row.paired {
|
|
(ConsoleEntry::Library(Box::new(row)), Some(label))
|
|
} else {
|
|
(ConsoleEntry::Home, Some(label))
|
|
}
|
|
}
|
|
None if fake => {
|
|
let row = fake_host_row();
|
|
(ConsoleEntry::Library(Box::new(row)), None)
|
|
}
|
|
None => (ConsoleEntry::Home, None),
|
|
};
|
|
let initial_fetch = match &entry {
|
|
ConsoleEntry::Library(h) => Some(ConsoleCmd::FetchLibrary {
|
|
addr: h.addr.clone(),
|
|
mgmt: h.mgmt_port,
|
|
fp_hex: h.fp_hex.clone(),
|
|
}),
|
|
ConsoleEntry::Home => None,
|
|
};
|
|
|
|
let opts = ConsoleOptions {
|
|
device_name: trust::device_name(),
|
|
deck: is_steam_deck(),
|
|
};
|
|
let (overlay, handles) = match SkiaOverlay::console(opts, entry) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("console UI: {e:#}");
|
|
return crate::session_main::EXIT_PRESENTER_FAILED;
|
|
}
|
|
};
|
|
let ConsoleHandles {
|
|
console,
|
|
library: library_model,
|
|
bus,
|
|
} = handles;
|
|
|
|
// The service loop: discovery, probes, wake, pairing, persistence, fetches.
|
|
let service = Service::start(
|
|
console.clone(),
|
|
library_model.clone(),
|
|
bus.clone(),
|
|
identity.clone(),
|
|
seed,
|
|
);
|
|
if let Some(cmd) = initial_fetch {
|
|
bus.send(cmd);
|
|
}
|
|
|
|
// `--json-status`: a shell parent is reading stdout (the WinUI shell hides itself on
|
|
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
|
let json_status = arg_flag("--json-status");
|
|
let settings_at_start = trust::Settings::load();
|
|
// The console's window and its input models are built ONCE, from the global defaults, and
|
|
// live across every launch — so the presentation-tier fields below (stats tier, touch and
|
|
// mouse model, shortcut inhibit, match-window, render scale) are latched here and a per-host
|
|
// profile cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec,
|
|
// audio, pad) is re-resolved per launch and does honor the binding. Closing that gap means
|
|
// rebuilding the presenter's models per launch — profiles P4 territory, not P0.
|
|
let latched_mouse = settings_at_start.mouse_mode();
|
|
|
|
// 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 {
|
|
window_title: window_label.map_or_else(
|
|
|| "Punktfunk".to_string(),
|
|
|label| format!("Punktfunk · {label}"),
|
|
),
|
|
fullscreen: fullscreen_mode(),
|
|
window_pos: window_pos(),
|
|
// `--stats` forces the overlay visible without demoting a richer chosen tier.
|
|
stats_verbosity: match settings_at_start.stats_verbosity() {
|
|
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
|
v => v,
|
|
},
|
|
touch_mode: settings_at_start.touch_mode(),
|
|
mouse_mode: settings_at_start.mouse_mode(),
|
|
invert_scroll: settings_at_start.invert_scroll,
|
|
inhibit_shortcuts: settings_at_start.inhibit_shortcuts,
|
|
// Presentation-tier like the rows above: latched at console start, a per-host
|
|
// profile cannot move it in this mode (the documented P4 gap).
|
|
present_priority: settings_at_start.present_priority(),
|
|
vsync: settings_at_start.vsync,
|
|
allow_vrr: settings_at_start.allow_vrr,
|
|
json_status,
|
|
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
|
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)),
|
|
window_size: crate::session_main::window_size(&settings_at_start),
|
|
// Latched at console start (like the stats tier above): toggling Match window in
|
|
// the console's settings screen applies from the next console launch.
|
|
// The console owns its own window across every launch, and no parent is listening to
|
|
// its stdout, so it keeps persisting the size itself.
|
|
match_window: crate::session_main::match_window(&settings_at_start, true),
|
|
render_scale: settings_at_start.render_scale,
|
|
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings_at_start.codec),
|
|
};
|
|
|
|
let result =
|
|
pf_presenter::run_browse(opts, |action, gamepad, native, force_software, vulkan| {
|
|
match action {
|
|
OverlayAction::Launch {
|
|
addr,
|
|
port,
|
|
fp_hex,
|
|
launch,
|
|
title,
|
|
request_access,
|
|
profile,
|
|
} => {
|
|
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
|
// Connect (and request-access) pin the host's advertised fingerprint;
|
|
// a pinless launch is a logic slip, never a silent TOFU.
|
|
tracing::warn!(%addr, "launch without a stored pin — refusing");
|
|
return ActionOutcome::Handled;
|
|
};
|
|
tracing::info!(%addr, %title, request_access,
|
|
launch = launch.as_deref().unwrap_or("desktop"),
|
|
"launching from the console");
|
|
// Settings re-resolve per launch: the console's own settings screen may
|
|
// have changed the defaults since the last stream, and the host may carry
|
|
// a profile binding. Console (and therefore Decky, which spawns this
|
|
// binary) honors bindings with no console-side work — the resolver is the
|
|
// same one `--connect` goes through. A pinned card's connect arrives as a
|
|
// one-off profile id; the resolver prefers it over the binding, and a
|
|
// dangling id falls back to the defaults without blocking the connect.
|
|
let (settings, profile) =
|
|
trust::effective_settings(&addr, port, profile.as_deref());
|
|
let mut params = session_params(
|
|
&settings,
|
|
profile.map(|p| p.name),
|
|
// In-process launch: no spawner resolved a clipboard decision for us.
|
|
None,
|
|
addr.clone(),
|
|
port,
|
|
pin,
|
|
identity.clone(),
|
|
launch,
|
|
gamepad,
|
|
native,
|
|
force_software,
|
|
vulkan,
|
|
);
|
|
// …with ONE field that must follow the latched model rather than this
|
|
// launch's: the cursor-channel advertisement says "this client draws the
|
|
// host cursor itself", which is only true while the presenter is in desktop
|
|
// mouse mode. A profile that flips `mouse_mode` here would make the host
|
|
// stop compositing the pointer into a presenter that isn't drawing one —
|
|
// a stream with no visible cursor at all.
|
|
params.cursor_forward = latched_mouse == trust::MouseMode::Desktop;
|
|
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
|
|
// Also run-loop-side: the clipboard belongs to SDL, which this callback
|
|
// has no handle on. Unreachable in practice — listed so adding an action
|
|
// to the enum keeps failing loudly here instead of falling into a
|
|
// wildcard that silently drops it.
|
|
OverlayAction::CopyText(_) => ActionOutcome::Handled,
|
|
OverlayAction::Quit => ActionOutcome::Quit,
|
|
}
|
|
});
|
|
|
|
service.stop();
|
|
|
|
match result {
|
|
Ok(()) => 0,
|
|
Err(e) => {
|
|
// The shell contract's terminal line (a clean quit needs none — stdout EOF
|
|
// already routes the shell back to its host list silently).
|
|
if json_status {
|
|
crate::session_main::json_line("error", &format!("{e:#}"), Some(false));
|
|
}
|
|
eprintln!("console: {e:#}");
|
|
crate::session_main::EXIT_PRESENTER_FAILED
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A console row key → its index in the known-hosts store. The key is the pinned
|
|
/// fingerprint when there is one, else `addr:port` (see the row builder), and a pinned
|
|
/// CARD's key carries the profile id past a NUL — the console strips that before it
|
|
/// sends a command, so nothing here has to.
|
|
fn index_for_key(known: &trust::KnownHosts, key: &str) -> Option<usize> {
|
|
known
|
|
.hosts
|
|
.iter()
|
|
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
|
|
.or_else(|| {
|
|
let (addr, port) = key.rsplit_once(':')?;
|
|
known.index_by_addr(addr, port.parse().ok()?)
|
|
})
|
|
}
|
|
|
|
fn host_display_name(name: &str, addr: &str) -> String {
|
|
if name.trim().is_empty() {
|
|
addr.to_string()
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
}
|
|
|
|
fn fake_host_row() -> HostRow {
|
|
HostRow {
|
|
key: "fake".into(),
|
|
name: "Demo Host".into(),
|
|
addr: "127.0.0.1".into(),
|
|
port: 9777,
|
|
fp_hex: String::new(),
|
|
paired: true,
|
|
saved: true,
|
|
online: true,
|
|
mgmt_port: library::DEFAULT_MGMT_PORT,
|
|
can_wake: false,
|
|
last_used: None,
|
|
os: "linux/arch/steamos".into(),
|
|
pin: None,
|
|
bound_profile: None,
|
|
}
|
|
}
|
|
|
|
/// The background service: owns discovery, probing, waking, pairing and persistence.
|
|
struct Service {
|
|
stop: Arc<AtomicBool>,
|
|
thread: Option<std::thread::JoinHandle<()>>,
|
|
}
|
|
|
|
impl Service {
|
|
fn start(
|
|
console: ConsoleShared,
|
|
library_model: LibraryShared,
|
|
bus: pf_console_ui::ConsoleBus,
|
|
identity: (String, String),
|
|
seed: Option<HostRow>,
|
|
) -> Service {
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let stop_w = stop.clone();
|
|
let thread = std::thread::Builder::new()
|
|
.name("punktfunk-console".into())
|
|
.spawn(move || {
|
|
ServiceState {
|
|
console,
|
|
library: library_model,
|
|
bus,
|
|
identity,
|
|
seed,
|
|
discovered: HashMap::new(),
|
|
probed: Arc::new(Mutex::new(HashMap::new())),
|
|
probe_inflight: Arc::new(AtomicBool::new(false)),
|
|
last_probe: Instant::now() - Duration::from_secs(60),
|
|
wake_cancel: None,
|
|
rescan: None,
|
|
}
|
|
.run(stop_w)
|
|
})
|
|
.ok();
|
|
Service { stop, thread }
|
|
}
|
|
|
|
fn stop(mut self) {
|
|
self.stop.store(true, Ordering::SeqCst);
|
|
if let Some(t) = self.thread.take() {
|
|
let _ = t.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ServiceState {
|
|
console: ConsoleShared,
|
|
library: LibraryShared,
|
|
bus: pf_console_ui::ConsoleBus,
|
|
identity: (String, String),
|
|
/// A `--browse` target that isn't in the store yet — kept on the list until the
|
|
/// store or discovery covers it.
|
|
seed: Option<HostRow>,
|
|
discovered: HashMap<String, discovery::DiscoveredHost>,
|
|
/// Probe results by row key, written by sweep threads.
|
|
probed: Arc<Mutex<HashMap<String, bool>>>,
|
|
probe_inflight: Arc<AtomicBool>,
|
|
last_probe: Instant,
|
|
/// Cancels the active wake thread (it owns the model's wake status).
|
|
wake_cancel: Option<Arc<AtomicBool>>,
|
|
/// Forces the mDNS browse to re-query. Installed by `run`; `None` before it starts.
|
|
rescan: Option<discovery::Rescan>,
|
|
}
|
|
|
|
impl ServiceState {
|
|
fn run(mut self, stop: Arc<AtomicBool>) {
|
|
let (discovery_rx, rescan) = discovery::browse();
|
|
self.rescan = Some(rescan);
|
|
while !stop.load(Ordering::SeqCst) {
|
|
// mDNS churn.
|
|
while let Ok(ev) = discovery_rx.try_recv() {
|
|
match ev {
|
|
discovery::DiscoveryEvent::Resolved(host) => {
|
|
self.discovered.insert(host.fullname.clone(), host);
|
|
}
|
|
discovery::DiscoveryEvent::Removed { fullname } => {
|
|
self.discovered.remove(&fullname);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shell commands (plus the binary's own seeded initial fetch).
|
|
for cmd in self.bus.drain() {
|
|
self.handle(cmd);
|
|
}
|
|
|
|
// The 10 s reachability sweep — saved hosts that don't advertise (routed /
|
|
// multicast-filtered networks) still get honest presence pips.
|
|
if self.last_probe.elapsed() >= Duration::from_secs(10) {
|
|
self.last_probe = Instant::now();
|
|
self.sweep();
|
|
}
|
|
|
|
self.console.set_hosts(self.rows());
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
if let Some(c) = &self.wake_cancel {
|
|
c.store(true, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
fn handle(&mut self, cmd: ConsoleCmd) {
|
|
match cmd {
|
|
ConsoleCmd::FetchLibrary { addr, mgmt, fp_hex } => {
|
|
spawn_fetch(
|
|
self.library.clone(),
|
|
addr,
|
|
mgmt,
|
|
self.identity.clone(),
|
|
trust::parse_hex32(&fp_hex),
|
|
);
|
|
}
|
|
ConsoleCmd::Pair {
|
|
addr,
|
|
port,
|
|
pin,
|
|
device_name,
|
|
} => {
|
|
// Prefer what the list already calls this host (advert or store).
|
|
let name = self
|
|
.rows()
|
|
.into_iter()
|
|
.find(|r| r.addr == addr && r.port == port)
|
|
.map_or_else(|| addr.clone(), |r| r.name);
|
|
self.console.set_pair(PairPhase::Busy);
|
|
let console = self.console.clone();
|
|
let identity = self.identity.clone();
|
|
std::thread::Builder::new()
|
|
.name("punktfunk-pair".into())
|
|
.spawn(move || {
|
|
match trust::pair_with_host(&addr, port, &identity, &pin, &device_name) {
|
|
Ok(fp) => {
|
|
let fp_hex = trust::hex(&fp);
|
|
trust::persist_host(&name, &addr, port, &fp_hex, true);
|
|
console.set_pair(PairPhase::Paired { key: fp_hex });
|
|
}
|
|
Err(e) => {
|
|
// Cause-specific wording (wrong PIN vs not-armed vs unreachable
|
|
// vs a typed host rejection) — shared with every other surface.
|
|
console.set_pair(PairPhase::Failed(trust::pair_error_message(&e)));
|
|
}
|
|
}
|
|
})
|
|
.ok();
|
|
}
|
|
ConsoleCmd::SaveHost { name, addr, port } => {
|
|
let mut known = trust::KnownHosts::load();
|
|
// Manual entries have no fingerprint yet, so `upsert` (fp-keyed) would
|
|
// collide two of them — key manual saves by address instead.
|
|
if let Some(h) = known
|
|
.index_by_addr(&addr, port)
|
|
.and_then(|i| known.hosts.get_mut(i))
|
|
{
|
|
if !name.is_empty() {
|
|
h.name = name;
|
|
}
|
|
} else {
|
|
known.hosts.push(trust::KnownHost {
|
|
name: if name.is_empty() { addr.clone() } else { name },
|
|
addr,
|
|
port,
|
|
..Default::default()
|
|
});
|
|
}
|
|
if let Err(e) = known.save() {
|
|
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
|
}
|
|
self.last_probe = Instant::now() - Duration::from_secs(60); // probe it now
|
|
}
|
|
ConsoleCmd::UpdateHost {
|
|
key,
|
|
name,
|
|
addr,
|
|
port,
|
|
} => {
|
|
let mut known = trust::KnownHosts::load();
|
|
let Some(h) = index_for_key(&known, &key).and_then(|i| known.hosts.get_mut(i))
|
|
else {
|
|
tracing::warn!(%key, "edit for an unknown host — ignoring");
|
|
return;
|
|
};
|
|
// Edited IN PLACE rather than removed and re-added: the fingerprint, the
|
|
// learned MAC, the pinned cards and the profile binding all hang off this
|
|
// entry, and re-adding would silently unpair a host the user only renamed.
|
|
h.name = if name.trim().is_empty() {
|
|
addr.clone()
|
|
} else {
|
|
name
|
|
};
|
|
h.addr = addr;
|
|
h.port = port;
|
|
if let Err(e) = known.save() {
|
|
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
|
}
|
|
self.last_probe = Instant::now() - Duration::from_secs(60); // the address moved
|
|
}
|
|
ConsoleCmd::ForgetHost { key } => {
|
|
let mut known = trust::KnownHosts::load();
|
|
let Some(i) = index_for_key(&known, &key) else {
|
|
tracing::warn!(%key, "forget for an unknown host — ignoring");
|
|
return;
|
|
};
|
|
let gone = known.hosts.remove(i);
|
|
if let Err(e) = known.save() {
|
|
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
|
}
|
|
tracing::info!(name = %gone.name, addr = %gone.addr, "host forgotten");
|
|
// It may still be advertising, in which case it comes straight back as a
|
|
// DISCOVERED row — unsaved and unpaired, which is the honest state.
|
|
self.last_probe = Instant::now() - Duration::from_secs(60);
|
|
}
|
|
ConsoleCmd::Wake { key, then_connect } => {
|
|
if let Some(c) = self.wake_cancel.take() {
|
|
c.store(true, Ordering::SeqCst);
|
|
}
|
|
let Some(row) = self.rows().into_iter().find(|r| r.key == key) else {
|
|
return;
|
|
};
|
|
let known = trust::KnownHosts::load();
|
|
let macs = known
|
|
.hosts
|
|
.iter()
|
|
.find(|h| {
|
|
h.fp_hex == row.fp_hex && !row.fp_hex.is_empty()
|
|
|| (h.addr == row.addr && h.port == row.port)
|
|
})
|
|
.map(|h| h.mac.clone())
|
|
.unwrap_or_default();
|
|
if macs.is_empty() {
|
|
self.console.set_pair(PairPhase::Idle); // no-op; keep state sane
|
|
return;
|
|
}
|
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
self.wake_cancel = Some(cancel.clone());
|
|
spawn_wake(self.console.clone(), row, macs, then_connect, cancel);
|
|
}
|
|
ConsoleCmd::CancelWake => {
|
|
if let Some(c) = self.wake_cancel.take() {
|
|
c.store(true, Ordering::SeqCst);
|
|
}
|
|
self.console.set_wake(None);
|
|
}
|
|
ConsoleCmd::Probe => {
|
|
self.last_probe = Instant::now() - Duration::from_secs(60);
|
|
// "Refresh presence" means the mDNS half too, not just the QUIC sweep: the browse
|
|
// runs for the process's lifetime and `mdns-sd` backs its re-query interval off to
|
|
// as much as an hour, so a host that appeared since startup may never be asked
|
|
// for again. (No console screen emits Probe yet — every face button on the home
|
|
// screen is spoken for — but the plumbing is correct for when one does.)
|
|
if let Some(r) = &self.rescan {
|
|
r.request();
|
|
}
|
|
}
|
|
ConsoleCmd::SetPin {
|
|
key,
|
|
profile_id,
|
|
pin,
|
|
} => {
|
|
// Presentation only (design §5.2a): order = card order, appended at the
|
|
// end; never touches `profile_id` (the default binding). Idempotent, so
|
|
// a repeated press inside one refresh window can't double-pin.
|
|
let mut known = trust::KnownHosts::load();
|
|
let idx = index_for_key(&known, &key);
|
|
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
|
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
|
|
return;
|
|
};
|
|
if pin && !h.pinned_profiles.contains(&profile_id) {
|
|
h.pinned_profiles.push(profile_id);
|
|
} else if !pin {
|
|
h.pinned_profiles.retain(|id| *id != profile_id);
|
|
}
|
|
if let Err(e) = known.save() {
|
|
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
|
}
|
|
// `run` refreshes the rows right after this drain, so the carousel and
|
|
// the pin screen reflect the new card within the same service pass.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One parallel reachability pass over every non-advertising row (advertising ones
|
|
/// are online by definition). Runs on its own thread; at most one in flight.
|
|
fn sweep(&self) {
|
|
if self.probe_inflight.swap(true, Ordering::SeqCst) {
|
|
return;
|
|
}
|
|
let targets: Vec<(String, (String, u16))> = self
|
|
.rows()
|
|
.into_iter()
|
|
.filter(|r| !self.advertised(r))
|
|
.map(|r| (r.key.clone(), (r.addr.clone(), r.port)))
|
|
.collect();
|
|
let probed = self.probed.clone();
|
|
let inflight = self.probe_inflight.clone();
|
|
std::thread::Builder::new()
|
|
.name("punktfunk-probe".into())
|
|
.spawn(move || {
|
|
let (keys, addrs): (Vec<_>, Vec<_>) = targets.into_iter().unzip();
|
|
let results = trust::probe_reachable_many(addrs, Duration::from_millis(900));
|
|
let mut map = probed.lock().unwrap();
|
|
for (key, ok) in keys.into_iter().zip(results) {
|
|
map.insert(key, ok);
|
|
}
|
|
inflight.store(false, Ordering::SeqCst);
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
fn advertised(&self, row: &HostRow) -> bool {
|
|
self.discovered.values().any(|d| {
|
|
(!row.fp_hex.is_empty() && d.fp_hex == row.fp_hex)
|
|
|| (d.addr == row.addr && d.port == row.port)
|
|
})
|
|
}
|
|
|
|
/// The console home's rows: saved hosts (most recent first) — each followed by its
|
|
/// pinned profile cards (design §5.2a) — then discovered-but-unsaved ones, then a
|
|
/// still-uncovered `--browse` seed.
|
|
fn rows(&self) -> Vec<HostRow> {
|
|
let known = trust::KnownHosts::load();
|
|
let catalog = pf_client_core::profiles::ProfilesFile::load();
|
|
let probed = self.probed.lock().unwrap();
|
|
let chip = |p: &pf_client_core::profiles::StreamProfile| pf_console_ui::ProfileChip {
|
|
id: p.id.clone(),
|
|
name: p.name.clone(),
|
|
accent: p.accent.clone(),
|
|
};
|
|
// Primary rows paired with their pinned cards, so the sort below can order hosts
|
|
// while every host's cards stay glued behind its primary tile.
|
|
let mut saved: Vec<(HostRow, Vec<HostRow>)> = known
|
|
.hosts
|
|
.iter()
|
|
.map(|h| {
|
|
let key = if h.fp_hex.is_empty() {
|
|
format!("{}:{}", h.addr, h.port)
|
|
} else {
|
|
h.fp_hex.clone()
|
|
};
|
|
let advert = self.discovered.values().find(|d| {
|
|
(!h.fp_hex.is_empty() && d.fp_hex == h.fp_hex)
|
|
|| (d.addr == h.addr && d.port == h.port)
|
|
});
|
|
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
|
let row = HostRow {
|
|
key: key.clone(),
|
|
name: host_display_name(&h.name, &h.addr),
|
|
addr: h.addr.clone(),
|
|
port: h.port,
|
|
fp_hex: h.fp_hex.clone(),
|
|
paired: h.paired,
|
|
saved: true,
|
|
online,
|
|
mgmt_port: advert
|
|
.and_then(|d| d.mgmt_port)
|
|
.unwrap_or(library::DEFAULT_MGMT_PORT),
|
|
can_wake: !online && !h.mac.is_empty(),
|
|
last_used: h.last_used,
|
|
os: advert
|
|
.filter(|d| !d.os.is_empty())
|
|
.map(|d| d.os.clone())
|
|
.unwrap_or_else(|| h.os.clone()),
|
|
pin: None,
|
|
bound_profile: h
|
|
.profile_id
|
|
.as_deref()
|
|
.and_then(|id| catalog.find_by_id(id))
|
|
.map(chip),
|
|
};
|
|
// A pinned card shares the primary tile's live state; its key rides the
|
|
// profile id behind a NUL (impossible in a fingerprint or `addr:port`),
|
|
// so cursor-follow and the wake path address the card itself.
|
|
let pins = h
|
|
.resolved_pins(&catalog)
|
|
.into_iter()
|
|
.map(|p| HostRow {
|
|
key: format!("{key}\0{}", p.id),
|
|
pin: Some(chip(p)),
|
|
bound_profile: None,
|
|
..row.clone()
|
|
})
|
|
.collect();
|
|
(row, pins)
|
|
})
|
|
.collect();
|
|
saved.sort_by(|(a, _), (b, _)| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
|
let mut rows: Vec<HostRow> = saved
|
|
.into_iter()
|
|
.flat_map(|(row, pins)| std::iter::once(row).chain(pins))
|
|
.collect();
|
|
|
|
let mut extra: Vec<HostRow> = self
|
|
.discovered
|
|
.values()
|
|
.filter(|d| {
|
|
!known.hosts.iter().any(|h| {
|
|
(!h.fp_hex.is_empty() && h.fp_hex == d.fp_hex)
|
|
|| (h.addr == d.addr && h.port == d.port)
|
|
})
|
|
})
|
|
.map(|d| HostRow {
|
|
key: if d.fp_hex.is_empty() {
|
|
format!("{}:{}", d.addr, d.port)
|
|
} else {
|
|
d.fp_hex.clone()
|
|
},
|
|
name: host_display_name(&d.name, &d.addr),
|
|
addr: d.addr.clone(),
|
|
port: d.port,
|
|
fp_hex: d.fp_hex.clone(),
|
|
paired: false,
|
|
saved: false,
|
|
online: true,
|
|
mgmt_port: d.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
|
can_wake: false,
|
|
last_used: None,
|
|
os: d.os.clone(),
|
|
pin: None,
|
|
bound_profile: None,
|
|
})
|
|
.collect();
|
|
extra.sort_by(|a, b| a.name.cmp(&b.name));
|
|
rows.extend(extra);
|
|
|
|
if let Some(seed) = &self.seed {
|
|
if !rows
|
|
.iter()
|
|
.any(|r| r.addr == seed.addr && r.port == seed.port)
|
|
{
|
|
let mut seed = seed.clone();
|
|
seed.online = probed.get(&seed.key).copied().unwrap_or(false);
|
|
rows.push(seed);
|
|
}
|
|
}
|
|
rows
|
|
}
|
|
}
|
|
|
|
/// The wake-and-wait loop (one per wake): re-send the magic packet every 6 s, probe the
|
|
/// host once a second, 90 s timeout — the Apple `HostWaker`'s cadence. The thread owns
|
|
/// the model's wake status; the shell reads `online`/`timed_out` and acts.
|
|
fn spawn_wake(
|
|
console: ConsoleShared,
|
|
row: HostRow,
|
|
macs: Vec<String>,
|
|
then_connect: bool,
|
|
cancel: Arc<AtomicBool>,
|
|
) {
|
|
std::thread::Builder::new()
|
|
.name("punktfunk-wake".into())
|
|
.spawn(move || {
|
|
let last_ip = row.addr.parse::<Ipv4Addr>().ok();
|
|
let started = Instant::now();
|
|
let mut last_packet: Option<Instant> = None;
|
|
loop {
|
|
if cancel.load(Ordering::SeqCst) {
|
|
console.set_wake(None);
|
|
return;
|
|
}
|
|
let elapsed = started.elapsed();
|
|
let timed_out = elapsed >= Duration::from_secs(90);
|
|
if !timed_out && last_packet.is_none_or(|t| t.elapsed() >= Duration::from_secs(6)) {
|
|
wol::wake(&macs, last_ip);
|
|
last_packet = Some(Instant::now());
|
|
}
|
|
let online = trust::probe_reachable_many(
|
|
vec![(row.addr.clone(), row.port)],
|
|
Duration::from_millis(900),
|
|
)
|
|
.first()
|
|
.copied()
|
|
.unwrap_or(false);
|
|
console.set_wake(Some(WakeStatus {
|
|
key: row.key.clone(),
|
|
name: row.name.clone(),
|
|
seconds: elapsed.as_secs() as u32,
|
|
timed_out,
|
|
online,
|
|
then_connect,
|
|
}));
|
|
if online || timed_out {
|
|
// Awake → the shell connects and cancels; timed out → the card
|
|
// waits for Try Again / Cancel. Either way this thread is done —
|
|
// a retry spawns a fresh one.
|
|
return;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(1000));
|
|
}
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
/// Fetch the library off the service thread, then stream poster art into the shared
|
|
/// model as results land (the renderer drains `push_art` per frame).
|
|
fn spawn_fetch(
|
|
shared: LibraryShared,
|
|
addr: String,
|
|
mgmt: u16,
|
|
identity: (String, String),
|
|
pin: Option<[u8; 32]>,
|
|
) {
|
|
shared.set_phase(LibraryPhase::Loading);
|
|
std::thread::Builder::new()
|
|
.name("punktfunk-library".into())
|
|
.spawn(move || {
|
|
if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") {
|
|
load_fake(&shared, &path);
|
|
return;
|
|
}
|
|
match library::fetch_games(&addr, mgmt, &identity, pin) {
|
|
Ok(games) => {
|
|
let base = library::base_url(&addr, mgmt);
|
|
let jobs: VecDeque<(String, Vec<String>)> = games
|
|
.iter()
|
|
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
|
|
.filter(|(_, candidates)| !candidates.is_empty())
|
|
.collect();
|
|
shared.set_games(
|
|
games
|
|
.iter()
|
|
.map(|g| LibraryGame {
|
|
id: g.id.clone(),
|
|
title: g.title.clone(),
|
|
store: g.store.clone(),
|
|
launcher: g.is_launcher(),
|
|
})
|
|
.collect(),
|
|
);
|
|
if !jobs.is_empty() {
|
|
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
|
while let Ok((id, bytes)) = rx.recv_blocking() {
|
|
shared.push_art(id, bytes);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => shared.set_phase(LibraryPhase::Error {
|
|
title: "Couldn't load the library".into(),
|
|
body: e.to_string(),
|
|
can_retry: true,
|
|
}),
|
|
}
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk.
|
|
fn load_fake(shared: &LibraryShared, path: &str) {
|
|
let games: Vec<library::GameEntry> = std::fs::read_to_string(path)
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default();
|
|
for g in &games {
|
|
if let Some(p) = g.art.portrait.as_deref().filter(|p| p.starts_with('/')) {
|
|
if let Ok(bytes) = std::fs::read(p) {
|
|
shared.push_art(g.id.clone(), bytes);
|
|
}
|
|
}
|
|
}
|
|
shared.set_games(
|
|
games
|
|
.iter()
|
|
.map(|g| LibraryGame {
|
|
id: g.id.clone(),
|
|
title: g.title.clone(),
|
|
store: g.store.clone(),
|
|
launcher: g.is_launcher(),
|
|
})
|
|
.collect(),
|
|
);
|
|
}
|