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 / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (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
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>
704 lines
26 KiB
Rust
704 lines
26 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};
|
|
|
|
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),
|
|
};
|
|
let label = row.name.clone();
|
|
if k.is_none() {
|
|
seed = Some(row.clone());
|
|
}
|
|
if row.paired {
|
|
(ConsoleEntry::Library(row), Some(label))
|
|
} else {
|
|
(ConsoleEntry::Home, Some(label))
|
|
}
|
|
}
|
|
None if fake => {
|
|
let row = fake_host_row();
|
|
(ConsoleEntry::Library(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: 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();
|
|
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(),
|
|
print_stats: settings_at_start.show_stats || arg_flag("--stats"),
|
|
json_status,
|
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
|
trust::touch_last_used(&trust::hex(&fingerprint));
|
|
})),
|
|
overlay: Some(Box::new(overlay)),
|
|
};
|
|
|
|
let result =
|
|
pf_presenter::run_browse(opts, |action, gamepad, native, force_software, vulkan| {
|
|
match action {
|
|
OverlayAction::Launch {
|
|
addr,
|
|
port,
|
|
fp_hex,
|
|
launch,
|
|
title,
|
|
} => {
|
|
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
|
// The console only offers Connect on paired rows; 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, launch = launch.as_deref().unwrap_or("desktop"),
|
|
"launching from the console");
|
|
// Settings re-load per launch: the console's own settings screen
|
|
// may have changed them since the last stream.
|
|
let settings = trust::Settings::load();
|
|
ActionOutcome::Start(Box::new(session_params(
|
|
&settings,
|
|
addr,
|
|
port,
|
|
pin,
|
|
identity.clone(),
|
|
launch,
|
|
gamepad,
|
|
native,
|
|
force_software,
|
|
vulkan,
|
|
)))
|
|
}
|
|
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The machine's name — what the host lists this client as after pairing.
|
|
fn device_name() -> String {
|
|
#[cfg(target_os = "linux")]
|
|
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
|
let s = s.trim();
|
|
if !s.is_empty() {
|
|
return s.to_string();
|
|
}
|
|
}
|
|
std::env::var("COMPUTERNAME")
|
|
.or_else(|_| std::env::var("HOSTNAME"))
|
|
.ok()
|
|
.filter(|s| !s.trim().is_empty())
|
|
.unwrap_or_else(|| "This device".into())
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
.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>>,
|
|
}
|
|
|
|
impl ServiceState {
|
|
fn run(mut self, stop: Arc<AtomicBool>) {
|
|
let discovery_rx = discovery::browse();
|
|
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) => {
|
|
let msg = match e {
|
|
punktfunk_core::PunktfunkError::Crypto => {
|
|
"Wrong PIN — check the host's Pairing page and try again."
|
|
.to_string()
|
|
}
|
|
punktfunk_core::PunktfunkError::Timeout => {
|
|
"The host didn't answer. Is it running and reachable?"
|
|
.to_string()
|
|
}
|
|
other => format!("Pairing failed: {other:?}"),
|
|
};
|
|
console.set_pair(PairPhase::Failed(msg));
|
|
}
|
|
}
|
|
})
|
|
.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
|
|
.hosts
|
|
.iter_mut()
|
|
.find(|h| h.addr == addr && h.port == port)
|
|
{
|
|
if !name.is_empty() {
|
|
h.name = name;
|
|
}
|
|
} else {
|
|
known.hosts.push(trust::KnownHost {
|
|
name: if name.is_empty() { addr.clone() } else { name },
|
|
addr,
|
|
port,
|
|
fp_hex: String::new(),
|
|
paired: false,
|
|
last_used: None,
|
|
mac: Vec::new(),
|
|
});
|
|
}
|
|
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::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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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), then
|
|
/// discovered-but-unsaved ones, then a still-uncovered `--browse` seed.
|
|
fn rows(&self) -> Vec<HostRow> {
|
|
let known = trust::KnownHosts::load();
|
|
let probed = self.probed.lock().unwrap();
|
|
let mut rows: 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);
|
|
HostRow {
|
|
key,
|
|
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,
|
|
}
|
|
})
|
|
.collect();
|
|
rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
|
|
|
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,
|
|
})
|
|
.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(),
|
|
})
|
|
.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(),
|
|
})
|
|
.collect(),
|
|
);
|
|
}
|