From ab6790ef6c87d39f3acbfd0493407541ff0dd498 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 8 Jul 2026 14:16:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(clients):=20dial-first=20reconnect=20?= =?UTF-8?q?=E2=80=94=20don't=20gate=20the=20connect=20on=20mDNS=20presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 64 +++++++++++------- .../Sources/PunktfunkClient/ContentView.swift | 33 +++++++--- .../Session/SessionModel.swift | 13 +++- clients/linux/src/app.rs | 42 +++++++++++- clients/linux/src/ui_hosts.rs | 4 +- clients/linux/src/ui_trust.rs | 11 ++-- clients/windows/src/app/connect.rs | 66 ++++++++++++++++--- clients/windows/src/app/hosts.rs | 11 ++-- 8 files changed, 184 insertions(+), 60 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 1ac162b6..f2fede25 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -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) } diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 97c89bad..f4937733 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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: diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index d794aad9..e1102211 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -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). diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index c20fcc61..336df5f5 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -58,6 +58,11 @@ pub struct AppModel { hosts: Controller, /// 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, /// 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 1–3, 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})" ))), diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 222fb4b7..8c2ce332 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -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 |_| { diff --git a/clients/linux/src/ui_trust.rs b/clients/linux/src/ui_trust.rs index a7d7b49c..9dbd9d97 100644 --- a/clients/linux/src/ui_trust.rs +++ b/clients/linux/src/ui_trust.rs @@ -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, diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index 131759ab..19871768 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -23,6 +23,32 @@ pub(crate) fn initiate( target: Target, set_screen: &AsyncSetState, set_status: &AsyncSetState, +) { + 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, + target: Target, + set_screen: &AsyncSetState, + set_status: &AsyncSetState, +) { + crate::wol::wake(&target.mac, target.addr.parse().ok()); + initiate_opts(ctx, target, set_screen, set_status, true) +} + +fn initiate_opts( + ctx: &Arc, + target: Target, + set_screen: &AsyncSetState, + set_status: &AsyncSetState, + 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>, + /// 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, target: Target, set_screen: &AsyncSetState, diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index ed8d87ed..72d02c1d 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -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); }