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 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,6 +241,9 @@ 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) {
|
||||||
|
// 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(
|
waker.start(
|
||||||
hostName = name,
|
hostName = name,
|
||||||
connectsAfter = true,
|
connectsAfter = true,
|
||||||
@@ -240,8 +252,9 @@ fun ConnectScreen(
|
|||||||
isOnline = { liveAdvert() != null },
|
isOnline = { liveAdvert() != null },
|
||||||
onOnline = {
|
onOnline = {
|
||||||
val live = liveAdvert()
|
val live = liveAdvert()
|
||||||
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
// Woke back on a new address? Re-key the saved record so it (and future
|
||||||
// point at the live one, then dial there.
|
// 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)) {
|
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))
|
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
@@ -249,6 +262,7 @@ fun ConnectScreen(
|
|||||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
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
|
||||||
|
startSessionDirect(
|
||||||
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
|
requestAccess: requestAccess, approvalReq: approvalReq,
|
||||||
|
onUnreachable: {
|
||||||
waker.start(
|
waker.start(
|
||||||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
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).
|
||||||
|
|||||||
@@ -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 1–3, see `update`).
|
/// The trust gate in front of every connect (rules 1–3, 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})"
|
||||||
))),
|
))),
|
||||||
|
|||||||
@@ -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 |_| {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user