refactor(clients): both shells wake through the shared state machine
audit / bun-audit (push) Successful in 21s
ci / web (push) Successful in 1m39s
ci / docs-site (push) Successful in 2m7s
audit / cargo-audit (push) Successful in 2m55s
apple / swift (push) Successful in 5m25s
ci / bench (push) Successful in 7m47s
ci / rust-arm64 (push) Successful in 10m28s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
windows-host / package (push) Failing after 11m22s
windows-host / winget-source (push) Skipped
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
deb / build-publish-client-arm64 (push) Failing after 6m0s
deb / build-publish (push) Successful in 13m3s
arch / build-publish (push) Successful in 16m27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
flatpak / build-publish (push) Successful in 6m50s
android / android (push) Successful in 19m52s
deb / build-publish-host (push) Successful in 16m54s
ci / rust (push) Successful in 22m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 20m33s
release / apple (push) Successful in 30m41s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m24s
apple / screenshots (push) Successful in 22m37s

The last of C0's duplication: GTK's `wake_and_connect` and the WinUI shell's ran their own
copies of the same 90 s / 6 s / 1 s loop, and the Windows one's comment said it "mirrors
the Apple HostWaker" — now it literally does, because both drive `WakeWait`. What is left
in each shell is the part that genuinely differs: its dialog or screen, its advert drain,
its re-key, and its route back into the trust gate.

Behavioural review, since a sleeping host to test against wasn't available:

- GTK polled every 500 ms and resent on a wall-clock timer; it now ticks once a second
  like every other implementation. Up to half a second slower to notice a host coming
  back, and exactly the reference cadence in exchange.
- GTK took the FIRST matching advert in a drain and returned mid-loop; it now drains fully
  and takes the last, i.e. the freshest address. Same connect, fresher re-key.
- Windows sent one packet before the loop AND one on its first pass; the machine owns the
  packets now, so that duplicate is gone. Resends still land at 6 s, 12 s, … and the
  budget still expires at 90 s.
- Both keep their own cancel path, their own re-key, and their own error surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 21:01:47 +02:00
co-authored by Claude Opus 5
parent 983ff8e78d
commit 37edb1cc1a
2 changed files with 83 additions and 64 deletions
+36 -25
View File
@@ -9,6 +9,7 @@ use crate::trust;
use crate::ui_hosts::ConnectRequest; use crate::ui_hosts::ConnectRequest;
use adw::prelude::*; use adw::prelude::*;
use gtk::glib; use gtk::glib;
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
use relm4::prelude::*; use relm4::prelude::*;
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a /// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
@@ -16,7 +17,11 @@ use relm4::prelude::*;
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few /// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved /// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog /// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
/// lets the user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s). /// lets the user cancel.
///
/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported
/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the
/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate.
pub fn wake_and_connect( pub fn wake_and_connect(
window: &adw::ApplicationWindow, window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>, sender: &ComponentSender<AppModel>,
@@ -40,23 +45,17 @@ pub fn wake_and_connect(
let sender = sender.clone(); let sender = sender.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
use std::time::{Duration, Instant}; use std::time::Duration;
let events = crate::discovery::browse(); let events = crate::discovery::browse();
let started = Instant::now(); let mut wait = WakeWait::new();
let budget = Duration::from_secs(90);
let resend = Duration::from_secs(6);
crate::wol::wake(&req.mac, req.addr.parse().ok());
let mut last_wake = Instant::now();
loop { loop {
if cancel.get() { if cancel.get() {
waiting.close(); waiting.close();
return; return;
} }
if last_wake.elapsed() >= resend { // Drain resolved adverts; a match (fingerprint, else addr:port) means it is up,
crate::wol::wake(&req.mac, req.addr.parse().ok()); // and carries the address it came back on.
last_wake = Instant::now(); let mut seen: Option<(String, u16)> = None;
}
// Drain resolved adverts; a match (fingerprint, else addr:port) = it's up.
while let Ok(ev) = events.try_recv() { while let Ok(ev) = events.try_recv() {
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else { let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
continue; continue;
@@ -66,30 +65,42 @@ pub fn wake_and_connect(
None => h.addr == req.addr && h.port == req.port, None => h.addr == req.addr && h.port == req.port,
}; };
if matched { if matched {
seen = Some((h.addr, h.port));
}
}
let tick = wait.tick(seen.is_some());
if tick.send_packet {
crate::wol::wake(&req.mac, req.addr.parse().ok());
}
match tick.outcome {
Some(WakeOutcome::Online) => {
waiting.close(); waiting.close();
let mut req = req.clone(); let mut req = req.clone();
// Re-key on a new DHCP lease so this + future connects dial the // Re-key on a new DHCP lease so this + future connects dial the
// live address. // live address.
if h.addr != req.addr || h.port != req.port { if let Some((addr, port)) =
seen.filter(|(a, p)| *a != req.addr || *p != req.port)
{
if let Some(fp) = &req.fp_hex { if let Some(fp) = &req.fp_hex {
trust::rekey_addr(fp, &h.addr, h.port); trust::rekey_addr(fp, &addr, port);
} }
req.addr = h.addr; req.addr = addr;
req.port = h.port; req.port = port;
} }
sender.input(AppMsg::Connect(req)); sender.input(AppMsg::Connect(req));
return; return;
} }
Some(WakeOutcome::TimedOut) => {
waiting.close();
sender.input(AppMsg::Toast(format!(
"Couldn't reach “{}” — is it powered and on the network?",
req.name
)));
return;
}
None => {}
} }
if started.elapsed() >= budget { glib::timeout_future(Duration::from_secs(1)).await;
waiting.close();
sender.input(AppMsg::Toast(format!(
"Couldn't reach “{}” — is it powered and on the network?",
req.name
)));
return;
}
glib::timeout_future(Duration::from_millis(500)).await;
} }
}); });
} }
+47 -39
View File
@@ -7,6 +7,7 @@ use super::style::*;
use super::{AppCtx, Screen, Svc, Target}; use super::{AppCtx, Screen, Svc, Target};
use crate::discovery::DiscoveredHost; use crate::discovery::DiscoveredHost;
use crate::trust::{self, KnownHost, KnownHosts}; use crate::trust::{self, KnownHost, KnownHosts};
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -418,15 +419,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
/// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it /// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it
/// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to /// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to
/// the host list. /// the host list.
///
/// The cadence is [`WakeWait`], shared with the GTK shell and ported from Apple's `HostWaker`
/// (design/client-architecture-split.md §3) — the comment this function used to carry ("mirrors
/// the Apple HostWaker") is now literally true instead of aspirational.
fn wake_and_connect( fn wake_and_connect(
ctx: &Arc<AppCtx>, ctx: &Arc<AppCtx>,
target: Target, target: Target,
set_screen: &AsyncSetState<Screen>, set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>, set_status: &AsyncSetState<String>,
) { ) {
// First packet now; the poll loop re-sends every RESEND_SECS (a single one can be missed, and // The packets are the wait's business: `WakeWait` asks for one on its first tick and every
// some NICs only wake on a fresh packet after dropping into a deeper sleep state). // 6 s after (a single one can be missed, and some NICs only wake on a fresh packet after
crate::wol::wake(&target.mac, target.addr.parse().ok()); // dropping into a deeper sleep state).
// A fresh cancel flag per wake, installed where the "Waking…" screen's Cancel button reads it // A fresh cancel flag per wake, installed where the "Waking…" screen's Cancel button reads it
// back (the same shared channel as the request-access flow); the poll loop checks the same `Arc`. // back (the same shared channel as the request-access flow); the poll loop checks the same `Arc`.
let cancel = Arc::new(AtomicBool::new(false)); let cancel = Arc::new(AtomicBool::new(false));
@@ -438,12 +443,9 @@ fn wake_and_connect(
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
std::thread::spawn(move || { std::thread::spawn(move || {
// Generous — a cold boot + service start can be a minute-plus; re-send periodically.
const TIMEOUT_SECS: u64 = 90;
const RESEND_SECS: u64 = 6;
let rx = crate::discovery::browse(); let rx = crate::discovery::browse();
let mut seen: Vec<DiscoveredHost> = Vec::new(); let mut seen: Vec<DiscoveredHost> = Vec::new();
let mut elapsed: u64 = 0; let mut wait = WakeWait::new();
loop { loop {
// Cancel already returned the UI to the host list — stop re-sending and tear down. // Cancel already returned the UI to the host list — stop re-sending and tear down.
if cancel.load(Ordering::SeqCst) { if cancel.load(Ordering::SeqCst) {
@@ -465,40 +467,46 @@ fn wake_and_connect(
_ => h.addr == target.addr && h.port == target.port, _ => h.addr == target.addr && h.port == target.port,
}) })
.map(|h| (h.addr.clone(), h.port)); .map(|h| (h.addr.clone(), h.port));
if let Some((addr, port)) = resolved {
let mut target = target.clone(); let tick = wait.tick(resolved.is_some());
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved host so if tick.send_packet {
// the pin stays reachable next time (keyed by fingerprint; addr/port overwritten,
// `paired`/`mac` preserved by `upsert`).
if addr != target.addr || port != target.port {
target.addr = addr;
target.port = port;
if let Some(fp) = target.fp_hex.clone() {
let mut k = KnownHosts::load();
k.upsert(KnownHost {
name: target.name.clone(),
addr: target.addr.clone(),
port: target.port,
fp_hex: fp,
mac: target.mac.clone(),
..Default::default()
});
let _ = k.save();
}
}
initiate(&ctx, target, &ss, &st);
return;
}
if elapsed >= TIMEOUT_SECS {
st.call("The host didn't come online.".to_string());
ss.call(Screen::Hosts);
return;
}
std::thread::sleep(Duration::from_secs(1));
elapsed += 1;
if elapsed % RESEND_SECS == 0 {
crate::wol::wake(&target.mac, target.addr.parse().ok()); crate::wol::wake(&target.mac, target.addr.parse().ok());
} }
match tick.outcome {
Some(WakeOutcome::Online) => {
let mut target = target.clone();
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved
// host so the pin stays reachable next time (keyed by fingerprint;
// addr/port overwritten, `paired`/`mac` preserved by `upsert`).
if let Some((addr, port)) =
resolved.filter(|(a, p)| *a != target.addr || *p != target.port)
{
target.addr = addr;
target.port = port;
if let Some(fp) = target.fp_hex.clone() {
let mut k = KnownHosts::load();
k.upsert(KnownHost {
name: target.name.clone(),
addr: target.addr.clone(),
port: target.port,
fp_hex: fp,
mac: target.mac.clone(),
..Default::default()
});
let _ = k.save();
}
}
initiate(&ctx, target, &ss, &st);
return;
}
Some(WakeOutcome::TimedOut) => {
st.call("The host didn't come online.".to_string());
ss.call(Screen::Hosts);
return;
}
None => {}
}
std::thread::sleep(Duration::from_secs(1));
} }
}); });
} }