WP3 of design/desktop-presentation-rebuild.md. The `vsync` and `allow_vrr` settings have existed since WP1 but nothing consumed them — the swapchain picked MAILBOX-or-FIFO once, from an env var, and froze. This makes them mean something, which is also what unblocks their settings rows (deliberately withheld from WP5 rather than shipped as dead switches). Present-mode selection is now a preference ladder, not a constant: * V-Sync off — IMMEDIATE, then FIFO_RELAXED, then the tear-free modes. Asking to tear and silently getting vsync is a lie, so the mode that actually took is named in the stats line and a refused preference is logged requested-vs-active. * V-Sync on + VRR allowed + fullscreen — FIFO first. On a variable-refresh panel with direct scanout the FIFO present IS the flip, so the panel follows the stream's cadence instead of a fixed grid; MAILBOX would decouple presents from scanout and re-quantize to the compositor's clock. This is only safe because WP2's glass gate bounds the standing queue that historically made FIFO costly. * Otherwise — MAILBOX then FIFO, the shipped default, unchanged. `PUNKTFUNK_PRESENT_MODE` still pins a mode outright and now falls back to the settings (rather than to mailbox) when the name is unknown. VRR detection is MEASURED, never queried. No portable query exists — SDL exposes none, Wayland does not report adaptive-sync state, Windows surfaces nothing through Vulkan — and the platforms that do answer have been caught lying (see the Android per-uid refresh-rate finding). The discriminator is quantization: on a fixed-refresh panel every on-glass instant lands on the vblank grid, so the spacing between presents is ~k×period for whole k even when the stream runs slower than the panel (it just picks a larger k); under real VRR the panel refreshes when we present, so the spacing follows our own cadence and sits off the grid. `CadenceProbe` folds each delta to its distance from the nearest multiple of the learned period and takes the median. Tri-state: it stays Unknown below 24 deltas and after a display change, so `vrr` is reported only when it has been measured — never inferred from what the display claims. Also fixes the read-once refresh rate: `native.refresh_hz` was sampled at startup and never revisited, so dragging the window to another monitor left a 60 Hz-seeded clock pacing a 144 Hz panel. `WindowEvent::DisplayChanged` now relearns the latch grid, resets the cadence verdict, and clears the served-slot latch. Settings rows for both, on all three surfaces (GTK, WinUI, console). The console's V-Sync row is reachable in Gaming Mode, which is the only editor a Deck user has. Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK client, 160 tests (the two new ones cover every ladder and both cadence regimes, including the case that matters most: a stream slower than a FIXED panel must still read as fixed). WinUI leg on the Windows runner .133: clippy=0 tests=0, against a tree proven by content to contain the edit. ⚠ On-glass validation is still owed and is NOT claimed here: every box with a real display was powered off when this landed, so the VRR ladder and the detector have been exercised only against synthetic stamps in unit tests. Rebase follow-up: `20de58a7` landed the same "panel grid can be wrong in both directions" defect fix on Android and extracted the corrected learner into `punktfunk_core::phase::PanelGrid` for the iOS and desktop presenters to share. This clock had the identical bug — it capped the learned period at the display mode's refresh, and the mode is only a CLAIM, so a display really running slower than it advertises pinned a grid whose instants never arrive, for the session, with no way back. Adopted the shared learner rather than carrying a second, buggier copy; still fed the window's MIN spacing, which preserves the k×period resistance the cap was actually aimed at while the streak requirement lets a genuinely slower panel be discovered. New test: seed 120 Hz, real panel 60 Hz, the clock must climb back out. Took the same commit's third lesson too: the adaptive margin widened on a latch over 1.5×period (a number picked here), and now widens on the latch exceeding one period plus the lead already applied — the slot actually aimed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
764 lines
31 KiB
Rust
764 lines
31 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(),
|
|
};
|
|
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: 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,
|
|
} => {
|
|
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. No one-off here: picking a profile is
|
|
// a desktop-shell affordance in v1, pinned cards are the console's.
|
|
let (settings, profile) = trust::effective_settings(&addr, port, None);
|
|
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
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// 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) => {
|
|
// 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::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,
|
|
os: advert
|
|
.filter(|d| !d.os.is_empty())
|
|
.map(|d| d.os.clone())
|
|
.unwrap_or_else(|| h.os.clone()),
|
|
}
|
|
})
|
|
.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,
|
|
os: d.os.clone(),
|
|
})
|
|
.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(),
|
|
);
|
|
}
|