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:
@@ -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).
|
||||
|
||||
@@ -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 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})"
|
||||
))),
|
||||
|
||||
@@ -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 |_| {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user