fix(clients): dial-first reconnect — don't gate the connect on mDNS presence

A saved host that stopped advertising on mDNS but has a learned Wake-on-LAN
MAC was routed into a wake-and-WAIT-for-mDNS flow instead of being dialed. For
a host reached over a routed network (Tailscale/VPN/another subnet) mDNS never
sees it, so the wait could never succeed and reconnects were impossible — the
host log showed no connection attempt at all. The tile's Offline pip and this
connect gate shared the same LAN-only `advertises`/`liveAdvert` predicate, so a
perfectly reachable host read as unreachable. The first connect worked only
because the MAC hadn't been learned yet (a direct dial); once learned, every
reconnect wedged in the waker.

All four full clients carried this pattern (deliberate mirrors of the Apple
`HostWaker`). Each now DIALS FIRST: fire the magic packet up front
(fire-and-forget — harmless if the host is awake, and a genuinely-asleep box is
booting while the dial times out) and attempt the connection unconditionally.
Only a FAILED dial falls into the visible "Waking…" wait, whose redial carries
no fallback so it can't loop. A fingerprint-mismatch/trust-rejection failure
means the host ANSWERED, so it never takes the wake path.

- apple: SessionModel.connect gains `onUnreachable`; startSession dials first.
- linux (relm4): AppMsg::WakeConnect wakes+dials; a per-request wake_fallback
  is armed and consumed in SessionExited, matched by fingerprint/address.
- windows: initiate_waking + ConnectOpts.wake_on_fail; the worker's Failed arm
  escalates to wake_and_connect only on a non-trust failure.
- android: doConnectDirect gains onFailure; doConnect wakes+dials first.

Decky was already correct (always launches `--connect`; WoL fire-and-forget).
Explicit "Wake host" menu actions are unchanged — waiting on mDNS is right when
the user explicitly asked to wake a box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 14:16:04 +02:00
parent 52856d4b6b
commit ab6790ef6c
8 changed files with 184 additions and 60 deletions
@@ -185,8 +185,10 @@ fun ConnectScreen(
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint // The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it // the host presented (as an unpaired known host) so the next connect goes straight through and it
// appears in the saved-hosts list. // appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) { // fallback) instead of the error status line — discovery is already restarted when it runs, so
// the wait can observe the host reappear.
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
val id = identity ?: run { val id = identity ?: run {
status = "Identity not ready yet — try again in a moment" status = "Identity not ready yet — try again in a moment"
return return
@@ -206,18 +208,25 @@ fun ConnectScreen(
} }
onConnected(handle) onConnected(handle)
} else { } else {
status = "Connection failed — check host/port, PIN, and logcat"
discovery.start() discovery.start()
if (onFailure != null) {
status = ""
onFailure()
} else {
status = "Connection failed — check host/port, PIN, and logcat"
}
} }
} }
} }
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved // Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from // host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay) // IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget // Tailscale/VPN/another subnet — is mDNS-blind forever, and gating the dial on presence bricked
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or // exactly those reconnects). A genuinely-asleep box is already booting while the dial times out;
// already seen live) dial straight through. // only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
// seen live) dial straight through.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) { fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
if (identity == null) { if (identity == null) {
status = "Identity not ready yet — try again in a moment" status = "Identity not ready yet — try again in a moment"
@@ -232,23 +241,28 @@ fun ConnectScreen(
if (kh != null) discovered.firstOrNull { kh.matches(it) } if (kh != null) discovered.firstOrNull { kh.matches(it) }
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort } else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) { if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
waker.start( // Fire-and-forget first packet (harmless if it's awake), then dial-first.
hostName = name, scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
connectsAfter = true, doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
macs = macs, waker.start(
lastIp = targetHost, hostName = name,
isOnline = { liveAdvert() != null }, connectsAfter = true,
onOnline = { macs = macs,
val live = liveAdvert() lastIp = targetHost,
// Woke back on a new address? Re-key the saved record so it (and future connects) isOnline = { liveAdvert() != null },
// point at the live one, then dial there. onOnline = {
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) { val live = liveAdvert()
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port)) // Woke back on a new address? Re-key the saved record so it (and future
savedHosts = knownHostStore.all() // connects) point at the live one, then dial there (no fallback on this
} // redial — a second failure surfaces as the plain error).
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex) if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
}, knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
) savedHosts = knownHostStore.all()
}
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
},
)
})
} else { } else {
doConnectDirect(targetHost, targetPort, name, pinHex) doConnectDirect(targetHost, targetPort, name, pinHex)
} }
@@ -422,14 +422,24 @@ struct ContentView: View {
host, launchID: launchID, allowTofu: allowTofu, host, launchID: launchID, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq) requestAccess: requestAccess, approvalReq: approvalReq)
} }
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come // Not advertising and we can wake it? DIAL FIRST anyway no mDNS advert does NOT mean
// back online a cold box takes far longer to boot than a connect will sit showing the // unreachable: a host reached over a routed network (Tailscale/VPN/another subnet) is
// "Waking" overlay meanwhile. Then connect. Otherwise dial straight away. // mDNS-blind forever, and gating the dial on presence bricked exactly those reconnects
// (the host log shows no connection attempt at all; the tile pip and this gate share the
// LAN-only `advertises` predicate). `prepareWake` inside the dial already fires the magic
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
// when that dial FAILS do we fall into the visible "Waking" wait a cold box takes far
// longer to boot than a connect will sit and redial once it's back on mDNS.
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) { if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
discovery.start() // so we can observe it reappear discovery.start() // so the wake-wait can observe it reappear
waker.start( startSessionDirect(
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address, host, launchID: launchID, allowTofu: allowTofu,
isOnline: { discovery.advertises(host) }, onOnline: go) requestAccess: requestAccess, approvalReq: approvalReq,
onUnreachable: {
waker.start(
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
isOnline: { discovery.advertises(host) }, onOnline: go)
})
} else { } else {
go() go()
} }
@@ -437,10 +447,12 @@ struct ContentView: View {
/// The actual dial reached directly when the host is awake, or from the waker once a woken /// The actual dial reached directly when the host is awake, or from the waker once a woken
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host /// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
/// is advertising (and is a harmless no-op otherwise). /// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
private func startSessionDirect( private func startSessionDirect(
_ host: StoredHost, launchID: String? = nil, _ host: StoredHost, launchID: String? = nil,
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
onUnreachable: (@MainActor () -> Void)? = nil
) { ) {
prepareWake(for: host) prepareWake(for: host)
// The delegated-approval wait prompt only makes sense once we're actually dialing set it // The delegated-approval wait prompt only makes sense once we're actually dialing set it
@@ -461,7 +473,8 @@ struct ContentView: View {
preferredCodec: preferredCodecByte, preferredCodec: preferredCodecByte,
launchID: launchID, launchID: launchID,
allowTofu: allowTofu, allowTofu: allowTofu,
requestAccess: requestAccess) requestAccess: requestAccess,
onUnreachable: onUnreachable)
} }
/// Learn-while-awake, wake-while-asleep run just before every connect: /// Learn-while-awake, wake-while-asleep run just before every connect:
@@ -135,6 +135,10 @@ final class SessionModel: ObservableObject {
/// successful connect streams directly (the approval IS the trust decision) the caller pins /// successful connect streams directly (the approval IS the trust decision) the caller pins
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert /// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
/// for the wait; nil = trust-on-first-use. /// for the wait; nil = trust-on-first-use.
/// `onUnreachable`, when set, replaces the "could not connect" alert for a plain connect
/// failure: the caller takes over recovery (the Wake-on-LAN wait for a host that stopped
/// advertising). It never fires for the delegated-approval path, whose failure text carries
/// its own instructions.
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32, func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
compositor: PunktfunkConnection.Compositor = .auto, compositor: PunktfunkConnection.Compositor = .auto,
gamepad: PunktfunkConnection.GamepadType = .auto, gamepad: PunktfunkConnection.GamepadType = .auto,
@@ -145,7 +149,8 @@ final class SessionModel: ObservableObject {
launchID: String? = nil, launchID: String? = nil,
allowTofu: Bool = false, allowTofu: Bool = false,
autoTrust: Bool = false, autoTrust: Bool = false,
requestAccess: Bool = false) { requestAccess: Bool = false,
onUnreachable: (@MainActor () -> Void)? = nil) {
guard phase == .idle else { return } guard phase == .idle else { return }
phase = .connecting phase = .connecting
activeHost = host activeHost = host
@@ -241,7 +246,11 @@ final class SessionModel: ObservableObject {
case .failure: case .failure:
self.phase = .idle self.phase = .idle
self.activeHost = nil self.activeHost = nil
if requestAccess { if let onUnreachable, !requestAccess {
// The caller owns recovery (wake-and-retry) no error alert here; its
// own overlay explains what's happening.
onUnreachable()
} else if requestAccess {
// The delegated-approval connect ended without being admitted: the // The delegated-approval connect ended without being admitted: the
// operator didn't approve it before the host's park window elapsed (or // operator didn't approve it before the host's park window elapsed (or
// the host was unreachable). // the host was unreachable).
+39 -3
View File
@@ -58,6 +58,11 @@ pub struct AppModel {
hosts: Controller<HostsPage>, hosts: Controller<HostsPage>,
/// One session child at a time — connects while one runs are ignored. /// One session child at a time — connects while one runs are ignored.
busy: bool, busy: bool,
/// Armed by [`AppMsg::WakeConnect`] (a dial to a host that isn't advertising but has a
/// known MAC): if THAT dial's child exits with a connect failure, `SessionExited` falls
/// back into the visible wake-and-wait instead of an error. Consumed on the next exit and
/// matched against the exiting request, so it can never redirect an unrelated failure.
wake_fallback: Option<ConnectRequest>,
/// The request-access "waiting for approval" dialog, closed on the first child /// The request-access "waiting for approval" dialog, closed on the first child
/// event. A shared slot (not a message): dialogs are main-thread GTK objects and /// event. A shared slot (not a message): dialogs are main-thread GTK objects and
/// `AppMsg` must stay `Send` for the session child's reader thread. /// `AppMsg` must stay `Send` for the session child's reader thread.
@@ -68,7 +73,9 @@ pub struct AppModel {
pub enum AppMsg { pub enum AppMsg {
/// The trust gate in front of every connect (rules 13, see `update`). /// The trust gate in front of every connect (rules 13, see `update`).
Connect(ConnectRequest), Connect(ConnectRequest),
/// Wake an offline saved host, poll until it advertises, then `Connect`. /// Connect to a saved host that isn't advertising but has a known MAC: fire a wake
/// packet and DIAL IMMEDIATELY (mDNS absence ≠ unreachable — routed/Tailscale hosts
/// never advertise here); only a failed dial falls into the visible wake-and-wait.
WakeConnect(ConnectRequest), WakeConnect(ConnectRequest),
/// The SPAKE2 PIN ceremony dialog. /// The SPAKE2 PIN ceremony dialog.
Pair(ConnectRequest), Pair(ConnectRequest),
@@ -192,6 +199,7 @@ impl SimpleComponent for AppModel {
gamepad: init.gamepad, gamepad: init.gamepad,
hosts, hosts,
busy: false, busy: false,
wake_fallback: None,
waiting: Rc::new(RefCell::new(None)), waiting: Rc::new(RefCell::new(None)),
}; };
install_actions(&model.window, &sender); install_actions(&model.window, &sender);
@@ -293,7 +301,15 @@ impl SimpleComponent for AppModel {
} }
AppMsg::WakeConnect(req) => { AppMsg::WakeConnect(req) => {
if !self.busy { if !self.busy {
crate::ui_trust::wake_and_connect(&self.window, &sender, req); // DIAL FIRST — no mDNS advert does NOT mean unreachable: a host reached over
// a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and
// gating the dial on presence bricked exactly those reconnects. Fire the magic
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
// box is already booting while the dial times out, arm the wake-wait fallback
// for THIS request, and connect immediately.
crate::wol::wake(&req.mac, req.addr.parse().ok());
self.wake_fallback = Some(req.clone());
sender.input(AppMsg::Connect(req));
} }
} }
AppMsg::Pair(req) => { AppMsg::Pair(req) => {
@@ -365,11 +381,23 @@ impl SimpleComponent for AppModel {
self.close_waiting(); self.close_waiting();
self.busy = false; self.busy = false;
self.hosts.emit(HostsMsg::SetConnecting(None)); self.hosts.emit(HostsMsg::SetConnecting(None));
// The dial-first wake fallback (armed by `WakeConnect`, consumed on every exit):
// a failed dial to the non-advertising host it was armed for falls into the
// visible wake-and-wait instead of an error alert. Matched by fingerprint (else
// address) so a stale armed request can never redirect another host's failure.
let wake_fb =
self.wake_fallback
.take()
.filter(|fb| match (&fb.fp_hex, &req.fp_hex) {
(Some(a), Some(b)) => a == b,
_ => fb.addr == req.addr && fb.port == req.port,
});
match (code, error, ended) { match (code, error, ended) {
(0, _, None) => {} // clean end — back on the hosts page, no noise (0, _, None) => {} // clean end — back on the hosts page, no noise
(0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)), (0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)),
(_, Some((_, true)), _) if !tofu => { (_, Some((_, true)), _) if !tofu => {
// The stored pin no longer matches (rotated cert or impostor). // The stored pin no longer matches (rotated cert or impostor). The host
// ANSWERED — never the wake fallback.
self.toast("Host fingerprint changed — re-pair with a PIN to continue"); self.toast("Host fingerprint changed — re-pair with a PIN to continue");
crate::ui_trust::pin_dialog( crate::ui_trust::pin_dialog(
&self.window, &self.window,
@@ -378,10 +406,18 @@ impl SimpleComponent for AppModel {
req, req,
); );
} }
// A fingerprint mismatch means the host ANSWERED — reachable, so the plain
// error arms below handle it; only a genuine connect failure wakes.
(_, Some((_, false)), _) if wake_fb.is_some() => {
crate::ui_trust::wake_and_connect(&self.window, &sender, req)
}
(_, Some((msg, _)), _) => self (_, Some((msg, _)), _) => self
.hosts .hosts
.emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))), .emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))),
(-1, None, _) => {} // killed (request-access cancel) — already handled (-1, None, _) => {} // killed (request-access cancel) — already handled
(_, None, _) if wake_fb.is_some() => {
crate::ui_trust::wake_and_connect(&self.window, &sender, req)
}
(code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!( (code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!(
"Stream session failed (punktfunk-session exit {code})" "Stream session failed (punktfunk-session exit {code})"
))), ))),
+3 -1
View File
@@ -306,7 +306,9 @@ impl relm4::factory::FactoryComponent for HostCard {
} }
overlay.add_controller(right_click); overlay.add_controller(right_click);
// Auto-wake: offline + a known MAC routes to wake-and-wait. // Auto-wake: not advertising + a known MAC routes to WakeConnect, which
// dials first (a routed/Tailscale host is mDNS-blind, not asleep) and only
// falls into the wake-and-wait when the dial fails.
let wake_first = !online && !req.mac.is_empty(); let wake_first = !online && !req.mac.is_empty();
let sender = sender.clone(); let sender = sender.clone();
returned.connect_activate(move |_| { returned.connect_activate(move |_| {
+6 -5
View File
@@ -11,11 +11,12 @@ use adw::prelude::*;
use gtk::glib; use gtk::glib;
use relm4::prelude::*; use relm4::prelude::*;
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet, /// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
/// then we poll mDNS until it comes back online — re-sending every few seconds up to a /// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
/// timeout — and route back into the trust gate, **re-keying the saved record if the /// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
/// host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog lets the /// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
/// user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s). /// 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).
pub fn wake_and_connect( pub fn wake_and_connect(
window: &adw::ApplicationWindow, window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>, sender: &ComponentSender<AppModel>,
+57 -9
View File
@@ -23,6 +23,32 @@ pub(crate) fn initiate(
target: Target, target: Target,
set_screen: &AsyncSetState<Screen>, set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>, set_status: &AsyncSetState<String>,
) {
initiate_opts(ctx, target, set_screen, set_status, false)
}
/// Dial-first for a saved host that isn't advertising but has a known MAC: fire the magic packet
/// now (fire-and-forget — harmless if it's awake, and a genuinely-asleep box is already booting
/// while the dial times out) and dial IMMEDIATELY. mDNS absence does NOT mean unreachable — a
/// host reached over a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and
/// gating the dial on presence bricked exactly those reconnects. Only a failed dial falls into
/// the visible [`wake_and_connect`] wait.
pub(crate) fn initiate_waking(
ctx: &Arc<AppCtx>,
target: Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
crate::wol::wake(&target.mac, target.addr.parse().ok());
initiate_opts(ctx, target, set_screen, set_status, true)
}
fn initiate_opts(
ctx: &Arc<AppCtx>,
target: Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
wake_on_fail: bool,
) { ) {
let known = KnownHosts::load(); let known = KnownHosts::load();
let pin = target let pin = target
@@ -36,10 +62,14 @@ pub(crate) fn initiate(
}) })
.and_then(|fp| trust::parse_hex32(&fp)); .and_then(|fp| trust::parse_hex32(&fp));
let opts = ConnectOpts {
wake_on_fail,
..ConnectOpts::default()
};
if let Some(pin) = pin { if let Some(pin) = pin {
connect(ctx, &target, Some(pin), set_screen, set_status); connect_with(ctx, &target, Some(pin), set_screen, set_status, opts);
} else if target.pair_optional { } else if target.pair_optional {
connect(ctx, &target, None, set_screen, set_status); // TOFU connect_with(ctx, &target, None, set_screen, set_status, opts); // TOFU
} else { } else {
*ctx.shared.target.lock().unwrap() = target; *ctx.shared.target.lock().unwrap() = target;
set_screen.call(Screen::Pair); set_screen.call(Screen::Pair);
@@ -141,6 +171,13 @@ pub(crate) struct ConnectOpts {
/// silently when the parked connect finally resolves — without touching a screen a new /// silently when the parked connect finally resolves — without touching a screen a new
/// session may already own. /// session may already own.
cancel: Option<Arc<AtomicBool>>, cancel: Option<Arc<AtomicBool>>,
/// Fall into the Wake-on-LAN wait ([`wake_and_connect`]) when THIS dial fails with a plain
/// connect failure (not a trust rejection). Set by the dial-first path for a saved host that
/// isn't advertising but has a known MAC — the dial is attempted unconditionally (mDNS
/// absence ≠ unreachable: routed/Tailscale hosts never advertise here), and only a real
/// failure escalates to the visible "Waking…" wait. The wait's own redial clears the flag,
/// so it can't loop.
wake_on_fail: bool,
} }
impl Default for ConnectOpts { impl Default for ConnectOpts {
@@ -150,6 +187,7 @@ impl Default for ConnectOpts {
persist_paired: false, persist_paired: false,
awaiting_approval: false, awaiting_approval: false,
cancel: None, cancel: None,
wake_on_fail: false,
} }
} }
} }
@@ -210,6 +248,8 @@ fn connect_with(
let tofu = pin.is_none(); let tofu = pin.is_none();
let persist_paired = opts.persist_paired; let persist_paired = opts.persist_paired;
let cancel = opts.cancel; let cancel = opts.cancel;
let wake_on_fail = opts.wake_on_fail;
let ctx = ctx.clone();
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone()); let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
let (ss, st) = (set_screen.clone(), set_status.clone()); let (ss, st) = (set_screen.clone(), set_status.clone());
let target = target.clone(); let target = target.clone();
@@ -264,8 +304,14 @@ fn connect_with(
gamepad.detach(); gamepad.detach();
if trust_rejected { if trust_rejected {
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen. // Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
// The host ANSWERED, so this never takes the wake fallback.
*shared.target.lock().unwrap() = target.clone(); *shared.target.lock().unwrap() = target.clone();
ss.call(Screen::Pair); ss.call(Screen::Pair);
} else if wake_on_fail {
// The dial-first attempt to a non-advertising host failed — it may genuinely
// be asleep. NOW wake and wait (its resolved redial uses default opts, so a
// second failure lands on the host list, not back here).
wake_and_connect(&ctx, target.clone(), &ss, &st);
} else { } else {
ss.call(Screen::Hosts); ss.call(Screen::Hosts);
} }
@@ -310,17 +356,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
persist_paired: true, persist_paired: true,
awaiting_approval: true, awaiting_approval: true,
cancel: Some(cancel), cancel: Some(cancel),
wake_on_fail: false,
}, },
); );
} }
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the tapped saved host is /// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the FALLBACK after a
/// offline but has a MAC, so send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS /// failed dial-first attempt ([`initiate_waking`]) to a non-advertising saved host with a MAC.
/// for the host to reappear — re-sending the packet periodically — on a bounded deadline. A cold box /// Send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS for the host to
/// takes far longer to POST/boot/re-advertise than a connect attempt will sit, so we can't just /// reappear — re-sending the packet periodically — on a bounded deadline (a cold box takes far
/// fire-and-dial. On reappearance we dial it (re-keying the saved host when it came back on a new /// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it
/// IP); on timeout or Cancel we return to the host list. /// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to
pub(crate) fn wake_and_connect( /// the host list.
fn wake_and_connect(
ctx: &Arc<AppCtx>, ctx: &Arc<AppCtx>,
target: Target, target: Target,
set_screen: &AsyncSetState<Screen>, set_screen: &AsyncSetState<Screen>,
+6 -5
View File
@@ -2,7 +2,7 @@
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename / //! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients. //! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
use super::connect::{initiate, wake_and_connect}; use super::connect::{initiate, initiate_waking};
use super::speed::SpeedState; use super::speed::SpeedState;
use super::style::*; use super::style::*;
use super::{Screen, Svc, Target}; use super::{Screen, Svc, Target};
@@ -390,11 +390,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
), ),
Some(menu), Some(menu),
Some(Box::new(move || { Some(Box::new(move || {
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on // Saved host with a known MAC that isn't advertising: fire a wake packet and
// the network (re-sending periodically) before dialing — a cold box boots far // DIAL IMMEDIATELY — mDNS absence ≠ unreachable (a routed/Tailscale host never
// slower than a connect will sit. An online host dials straight away. // advertises here); only a failed dial falls into the "Waking…" wait. An
// online host dials straight away.
if can_wake { if can_wake {
wake_and_connect(&ctx2, target.clone(), &ss, &st); initiate_waking(&ctx2, target.clone(), &ss, &st);
} else { } else {
initiate(&ctx2, target.clone(), &ss, &st); initiate(&ctx2, target.clone(), &ss, &st);
} }