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 host presented (as an unpaired known host) so the next connect goes straight through and it
// appears in the saved-hosts list.
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
// 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 {
status = "Identity not ready yet — try again in a moment"
return
@@ -206,18 +208,25 @@ fun ConnectScreen(
}
onConnected(handle)
} else {
status = "Connection failed — check host/port, PIN, and logcat"
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
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
// already seen live) dial straight through.
// host with a learned MAC that ISN'T currently advertising, fire a wake packet 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). A genuinely-asleep box is already booting while the dial times out;
// 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?) {
if (identity == null) {
status = "Identity not ready yet — try again in a moment"
@@ -232,23 +241,28 @@ fun ConnectScreen(
if (kh != null) discovered.firstOrNull { kh.matches(it) }
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
waker.start(
hostName = name,
connectsAfter = true,
macs = macs,
lastIp = targetHost,
isOnline = { liveAdvert() != null },
onOnline = {
val live = liveAdvert()
// Woke back on a new address? Re-key the saved record so it (and future connects)
// point at the live one, then dial there.
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)
},
)
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
waker.start(
hostName = name,
connectsAfter = true,
macs = macs,
lastIp = targetHost,
isOnline = { liveAdvert() != null },
onOnline = {
val live = liveAdvert()
// Woke back on a new address? Re-key the saved record so it (and future
// connects) point at the live one, then dial there (no fallback on this
// redial — a second failure surfaces as the plain error).
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 {
doConnectDirect(targetHost, targetPort, name, pinHex)
}
@@ -422,14 +422,24 @@ struct ContentView: View {
host, launchID: launchID, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq)
}
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
// back online a cold box takes far longer to boot than a connect will sit showing the
// "Waking" overlay meanwhile. Then connect. Otherwise dial straight away.
// Not advertising and we can wake it? DIAL FIRST anyway 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
// (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) {
discovery.start() // so we can observe it reappear
waker.start(
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
isOnline: { discovery.advertises(host) }, onOnline: go)
discovery.start() // so the wake-wait can observe it reappear
startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq,
onUnreachable: {
waker.start(
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
isOnline: { discovery.advertises(host) }, onOnline: go)
})
} else {
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
/// 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(
_ 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)
// The delegated-approval wait prompt only makes sense once we're actually dialing set it
@@ -461,7 +473,8 @@ struct ContentView: View {
preferredCodec: preferredCodecByte,
launchID: launchID,
allowTofu: allowTofu,
requestAccess: requestAccess)
requestAccess: requestAccess,
onUnreachable: onUnreachable)
}
/// 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
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
/// 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,
compositor: PunktfunkConnection.Compositor = .auto,
gamepad: PunktfunkConnection.GamepadType = .auto,
@@ -145,7 +149,8 @@ final class SessionModel: ObservableObject {
launchID: String? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false,
requestAccess: Bool = false) {
requestAccess: Bool = false,
onUnreachable: (@MainActor () -> Void)? = nil) {
guard phase == .idle else { return }
phase = .connecting
activeHost = host
@@ -241,7 +246,11 @@ final class SessionModel: ObservableObject {
case .failure:
self.phase = .idle
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
// operator didn't approve it before the host's park window elapsed (or
// the host was unreachable).
+39 -3
View File
@@ -58,6 +58,11 @@ pub struct AppModel {
hosts: Controller<HostsPage>,
/// One session child at a time — connects while one runs are ignored.
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
/// 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.
@@ -68,7 +73,9 @@ pub struct AppModel {
pub enum AppMsg {
/// The trust gate in front of every connect (rules 13, see `update`).
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),
/// The SPAKE2 PIN ceremony dialog.
Pair(ConnectRequest),
@@ -192,6 +199,7 @@ impl SimpleComponent for AppModel {
gamepad: init.gamepad,
hosts,
busy: false,
wake_fallback: None,
waiting: Rc::new(RefCell::new(None)),
};
install_actions(&model.window, &sender);
@@ -293,7 +301,15 @@ impl SimpleComponent for AppModel {
}
AppMsg::WakeConnect(req) => {
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) => {
@@ -365,11 +381,23 @@ impl SimpleComponent for AppModel {
self.close_waiting();
self.busy = false;
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) {
(0, _, None) => {} // clean end — back on the hosts page, no noise
(0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)),
(_, 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");
crate::ui_trust::pin_dialog(
&self.window,
@@ -378,10 +406,18 @@ impl SimpleComponent for AppModel {
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
.hosts
.emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))),
(-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!(
"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);
// 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 sender = sender.clone();
returned.connect_activate(move |_| {
+6 -5
View File
@@ -11,11 +11,12 @@ use adw::prelude::*;
use gtk::glib;
use relm4::prelude::*;
/// Wake-and-wait: an **offline** saved host with a known MAC is 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 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).
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
/// 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
/// 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(
window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>,
+57 -9
View File
@@ -23,6 +23,32 @@ pub(crate) fn initiate(
target: Target,
set_screen: &AsyncSetState<Screen>,
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 pin = target
@@ -36,10 +62,14 @@ pub(crate) fn initiate(
})
.and_then(|fp| trust::parse_hex32(&fp));
let opts = ConnectOpts {
wake_on_fail,
..ConnectOpts::default()
};
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 {
connect(ctx, &target, None, set_screen, set_status); // TOFU
connect_with(ctx, &target, None, set_screen, set_status, opts); // TOFU
} else {
*ctx.shared.target.lock().unwrap() = target;
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
/// session may already own.
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 {
@@ -150,6 +187,7 @@ impl Default for ConnectOpts {
persist_paired: false,
awaiting_approval: false,
cancel: None,
wake_on_fail: false,
}
}
}
@@ -210,6 +248,8 @@ fn connect_with(
let tofu = pin.is_none();
let persist_paired = opts.persist_paired;
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 (ss, st) = (set_screen.clone(), set_status.clone());
let target = target.clone();
@@ -264,8 +304,14 @@ fn connect_with(
gamepad.detach();
if trust_rejected {
// 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();
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 {
ss.call(Screen::Hosts);
}
@@ -310,17 +356,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
persist_paired: true,
awaiting_approval: true,
cancel: Some(cancel),
wake_on_fail: false,
},
);
}
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the tapped saved host is
/// offline but has a MAC, so send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS
/// for the host to reappear — re-sending the packet periodically — on a bounded deadline. A cold box
/// takes far longer to POST/boot/re-advertise than a connect attempt will sit, so we can't just
/// fire-and-dial. 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 the host list.
pub(crate) fn wake_and_connect(
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the FALLBACK after a
/// failed dial-first attempt ([`initiate_waking`]) to a non-advertising saved host with a MAC.
/// Send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS for the host to
/// reappear — re-sending the packet periodically — on a bounded deadline (a cold box takes far
/// 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
/// the host list.
fn wake_and_connect(
ctx: &Arc<AppCtx>,
target: Target,
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 /
//! 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::style::*;
use super::{Screen, Svc, Target};
@@ -390,11 +390,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
),
Some(menu),
Some(Box::new(move || {
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
// the network (re-sending periodically) before dialing — a cold box boots far
// slower than a connect will sit. An online host dials straight away.
// Saved host with a known MAC that isn't advertising: fire a wake packet and
// DIAL IMMEDIATELY — mDNS absence ≠ unreachable (a routed/Tailscale host never
// advertises here); only a failed dial falls into the "Waking…" wait. An
// online host dials straight away.
if can_wake {
wake_and_connect(&ctx2, target.clone(), &ss, &st);
initiate_waking(&ctx2, target.clone(), &ss, &st);
} else {
initiate(&ctx2, target.clone(), &ss, &st);
}