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 2621c04..8b81305 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 @@ -124,6 +124,25 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { val identityStore = remember { IdentityStore(context) } val knownHostStore = remember { KnownHostStore(context) } var savedHosts by remember { mutableStateOf(knownHostStore.all()) } + // Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients), + // so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the + // prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC + // was actually newly learned. + LaunchedEffect(discovered) { + val learned = withContext(Dispatchers.IO) { + var any = false + discovered.forEach { dh -> + if (dh.mac.isNotEmpty() && + knownHostStore.get(dh.host, dh.port)?.let { it.mac != dh.mac } == true + ) { + knownHostStore.learnMac(dh.host, dh.port, dh.mac) + any = true + } + } + any + } + if (learned) savedHosts = knownHostStore.all() + } // Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and // refuses to connect — never silently shadow-minting a new identity (which would force re-pair). var identity by remember { mutableStateOf(null) } @@ -176,6 +195,14 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { } connecting = true status = "Connecting to $targetHost:$targetPort…" + // Auto-wake: reconnecting to a saved host that may be asleep. If we learned its MAC while it + // was online and it isn't currently advertising, fire a magic packet first — the connect's + // own timeout gives a woken host time to come up (harmless if it's already awake). + knownHostStore.get(targetHost, targetPort)?.mac + ?.takeIf { it.isNotEmpty() && discovered.none { d -> d.host == targetHost && d.port == targetPort } } + ?.let { macs -> + scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) } + } discovery.stop() // free the Wi-Fi radio before the stream session scope.launch { val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS) @@ -359,6 +386,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { savedHosts = knownHostStore.all() }, onRename = { renameTarget = kh }, + // Explicit wake: offered only when the host is offline and we have a MAC to + // target (a tap-to-connect already auto-wakes an offline saved host). + onWake = if (kh.mac.isNotEmpty() && + discovered.none { it.host == kh.address && it.port == kh.port } + ) { + { scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address) } } + } else { + null + }, ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt index 7d0b959..43b34e9 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt @@ -60,6 +60,7 @@ fun HostCard( onConnect: () -> Unit, onForget: (() -> Unit)?, onRename: (() -> Unit)? = null, + onWake: (() -> Unit)? = null, ) { // D-pad / controller focus highlight: a clickable card is focusable, but the default state // layer is too subtle on a TV across a room — draw a clear primary-colour border when focused. @@ -107,7 +108,7 @@ fun HostCard( StatusPill(status) } - if (onForget != null || onRename != null) { + if (onForget != null || onRename != null || onWake != null) { var menu by remember { mutableStateOf(false) } Box(modifier = Modifier.align(Alignment.TopEnd)) { IconButton(enabled = enabled, onClick = { menu = true }) { @@ -119,6 +120,15 @@ fun HostCard( ) } DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + if (onWake != null) { + DropdownMenuItem( + text = { Text("Wake host") }, + onClick = { + menu = false + onWake() + }, + ) + } if (onRename != null) { DropdownMenuItem( text = { Text("Rename") }, diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 1f49ae0..2875ffb 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -86,7 +86,7 @@ object NativeBridge { /** * The current resolved-host snapshot for [handle]: newline-joined records, each - * `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz; + * `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz; * cheap (a lock + string build), safe to call on the main thread. */ external fun nativeDiscoveryPoll(handle: Long): String @@ -94,6 +94,15 @@ object NativeBridge { /** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */ external fun nativeDiscoveryStop(handle: Long) + /** + * Send a Wake-on-LAN magic packet to wake a sleeping host. [macsCsv] is comma-separated MAC + * addresses (`aa:bb:..,cc:dd:..`), learned from the host's mDNS `mac` TXT while it was online; + * [lastIp] is the host's last-known IPv4 (or empty). Returns true if at least one datagram was + * sent. No handle — callable without a live session. Do NOT call on the main thread (it does + * blocking socket sends); run it on a background dispatcher. + */ + external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean + /** * Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs * entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started. diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt index a52746e..35e1190 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt @@ -17,15 +17,17 @@ data class DiscoveredHost( val port: Int, val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies) val pairingRequired: Boolean = false, + val mac: List = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN) ) /** Field separator the native browse uses inside one record (ASCII Unit Separator). */ private const val FIELD_SEP = '\u001F' /** - * Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null - * if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side - * already applied the protocol gate and address selection, so this is just field marshaling. + * Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac`), or + * null if it's malformed. `mac` (7th field) is optional — an older host omits it. Pure — + * unit-tested without Android (see ParseRecordTest). The native side already applied the protocol + * gate and address selection, so this is just field marshaling. */ fun parseHostRecord(record: String): DiscoveredHost? { val f = record.split(FIELD_SEP) @@ -40,6 +42,8 @@ fun parseHostRecord(record: String): DiscoveredHost? { port = port, fingerprint = f[4].ifBlank { null }, pairingRequired = f[5] == "required", + mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() } + else emptyList(), ) } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt index 277b6ac..a651497 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/security/KnownHostStore.kt @@ -13,6 +13,11 @@ data class KnownHost( val name: String, val fpHex: String, val paired: Boolean, + /** + * Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was + * online, so the client can wake it once it sleeps. Empty until first learned. + */ + val mac: List = emptyList(), ) /** @@ -42,9 +47,22 @@ class KnownHostStore(context: Context) { .put("name", host.name) .put("fp", host.fpHex.lowercase()) .put("paired", host.paired) + .put("mac", host.mac.joinToString(",")) prefs.edit().putString(key(host.address, host.port), json.toString()).apply() } + /** + * Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while online). + * No-op when the host isn't saved, the list is empty, or it's unchanged — so it doesn't churn + * prefs on every discovery tick. + */ + fun learnMac(address: String, port: Int, mac: List) { + if (mac.isEmpty()) return + val h = get(address, port) ?: return + if (h.mac == mac) return + save(h.copy(mac = mac)) + } + /** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */ fun remove(address: String, port: Int) { prefs.edit().remove(key(address, port)).apply() @@ -68,6 +86,7 @@ class KnownHostStore(context: Context) { name = j.getString("name"), fpHex = j.getString("fp"), paired = j.optBoolean("paired", false), + mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() }, ) }.getOrNull() } diff --git a/clients/android/native/src/discovery.rs b/clients/android/native/src/discovery.rs index e36e0fb..d68da71 100644 --- a/clients/android/native/src/discovery.rs +++ b/clients/android/native/src/discovery.rs @@ -31,7 +31,7 @@ const PROTO: &str = "punktfunk/1"; /// Field separator inside one serialized record (ASCII Unit Separator — never in a field value). const FIELD_SEP: char = '\u{1f}'; -/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]). +/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = [`FIELD_SEP`]). /// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from /// every field so no value can break it. #[derive(Clone, PartialEq)] @@ -42,6 +42,8 @@ struct Host { port: u16, fp: String, pair: String, + /// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated), for later wake. Empty if absent. + mac: String, } impl Host { @@ -54,13 +56,14 @@ impl Host { s.replace(['\n', '\r', FIELD_SEP], "") } format!( - "{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}", + "{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}", clean(&self.key), clean(&self.name), clean(&self.addr), self.port, clean(&self.fp), clean(&self.pair), + clean(&self.mac), ) } } @@ -182,6 +185,7 @@ fn resolve(info: &ResolvedService) -> Option { port: info.get_port(), fp: val("fp"), pair: val("pair"), + mac: val("mac"), }) } @@ -202,7 +206,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt } /// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot, -/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / +/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts / /// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build). #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>( @@ -263,16 +267,18 @@ mod tests { port: 9777, fp: "ab".repeat(32), pair: "required".into(), + mac: "aa:bb:cc:dd:ee:ff".into(), }; let encoded = h.encode(); let fields: Vec<&str> = encoded.split(FIELD_SEP).collect(); - assert_eq!(fields.len(), 6); + assert_eq!(fields.len(), 7); assert_eq!(fields[0], "host-123"); assert_eq!(fields[1], "home-worker-2"); assert_eq!(fields[2], "192.168.1.70"); assert_eq!(fields[3], "9777"); assert_eq!(fields[4], "ab".repeat(32)); assert_eq!(fields[5], "required"); + assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff"); assert!( !encoded.contains('\n'), "a record must never contain the record separator" @@ -282,7 +288,7 @@ mod tests { #[test] fn encode_strips_injected_separators_from_a_hostile_advert() { // A rogue advert could carry framing bytes in its instance label / TXT; encode must strip - // them so the snapshot stays exactly one record of exactly six fields. + // them so the snapshot stays exactly one record of exactly seven fields. let h = Host { key: "k\u{1f}injected".into(), name: "evil\nhost\r".into(), @@ -290,9 +296,10 @@ mod tests { port: 9777, fp: "ab\u{1f}cd".into(), pair: "required\n".into(), + mac: "aa:bb\u{1f}cc".into(), }; let encoded = h.encode(); - assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields"); + assert_eq!(encoded.matches(FIELD_SEP).count(), 6, "exactly seven fields"); assert!(!encoded.contains('\n') && !encoded.contains('\r')); let fields: Vec<&str> = encoded.split(FIELD_SEP).collect(); assert_eq!(fields[0], "kinjected"); diff --git a/clients/android/native/src/lib.rs b/clients/android/native/src/lib.rs index 7efba50..25dee21 100644 --- a/clients/android/native/src/lib.rs +++ b/clients/android/native/src/lib.rs @@ -39,6 +39,9 @@ mod feedback; mod mic; mod session; mod stats; +// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links +// into the host workspace build too. Kotlin only ever calls it on device. +mod wol; /// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the /// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build. diff --git a/clients/android/native/src/wol.rs b/clients/android/native/src/wol.rs new file mode 100644 index 0000000..5e32d42 --- /dev/null +++ b/clients/android/native/src/wol.rs @@ -0,0 +1,40 @@ +//! JNI seam for Wake-on-LAN: parse the stored MAC strings and hand them to the shared core sender +//! (`punktfunk_core::wol`). Like [`crate::discovery`], this takes no session handle — a sleeping +//! host has no ARP entry, so the broadcast the core sends is what wakes it, and Kotlin calls this +//! just before connecting to an offline saved host. + +use jni::objects::{JObject, JString}; +use jni::JNIEnv; + +/// `NativeBridge.nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean` — send a Wake-on-LAN +/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's +/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty). +/// Returns true if at least one datagram went out. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>( + mut env: JNIEnv<'local>, + _this: JObject<'local>, + macs_csv: JString<'local>, + last_ip: JString<'local>, +) -> jni::sys::jboolean { + let macs_csv: String = match env.get_string(&macs_csv) { + Ok(s) => s.into(), + Err(_) => return 0, + }; + let last_ip: String = env + .get_string(&last_ip) + .map(|s| Into::::into(s)) + .unwrap_or_default(); + let macs: Vec<[u8; 6]> = macs_csv + .split(',') + .filter_map(|s| punktfunk_core::wol::parse_mac(s.trim())) + .collect(); + if macs.is_empty() { + return 0; + } + let ip = last_ip.trim().parse::().ok(); + match punktfunk_core::wol::send_magic_packet(&macs, ip) { + Ok(()) => 1, + Err(_) => 0, + } +} diff --git a/clients/apple/Config/Punktfunk.entitlements b/clients/apple/Config/Punktfunk.entitlements index a934dc3..7ef8c24 100644 --- a/clients/apple/Config/Punktfunk.entitlements +++ b/clients/apple/Config/Punktfunk.entitlements @@ -11,5 +11,22 @@ $(AppIdentifierPrefix)io.unom.punktfunk + + diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index bb9f66a..59cd5a3 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -408,6 +408,7 @@ struct ContentView: View { _ host: StoredHost, launchID: String? = nil, allowTofu: Bool, requestAccess: Bool = false ) { + prepareWake(for: host) model.connect( to: host, width: UInt32(clamping: width), height: UInt32(clamping: height), @@ -426,6 +427,25 @@ struct ContentView: View { requestAccess: requestAccess) } + /// Learn-while-awake, wake-while-asleep — run just before every connect: + /// • host currently advertising (awake) → refresh its stored Wake-on-LAN MAC(s) from the live + /// advert, so a later wake has an up-to-date target; + /// • host NOT advertising (likely asleep/off) and we have MAC(s) → fire a magic packet first. + /// The connect that follows already retries/times out long enough for a woken host to come + /// up; if it's genuinely off/unreachable the connect fails as before. Best-effort and + /// non-blocking (the send runs off the main thread). + private func prepareWake(for host: StoredHost) { + if let live = discovery.hosts.first(where: { host.matches($0) }) { + store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform + } else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty { + let macs = host.wakeMacs + let ip = host.address + DispatchQueue.global(qos: .userInitiated).async { + PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip) + } + } + } + /// The no-PIN delegated-approval flow: open an identified connect the host parks until the /// operator approves it in the console, showing the cancelable "Waiting for approval" prompt /// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned @@ -455,7 +475,9 @@ struct ContentView: View { /// inside `connect`.) private func connectDiscovered(_ d: DiscoveredHost) { guard !model.isBusy else { return } - let host = StoredHost(name: d.name, address: d.host, port: d.port) + let host = StoredHost( + name: d.name, address: d.host, port: d.port, + macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses) store.add(host) if d.allowsTofu { connect(host, allowTofu: true) diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index 13cc9e0..4a10e3a 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -154,7 +154,14 @@ struct HomeView: View { onSpeedTest: { if !model.isBusy { speedTestTarget = host } }, onForget: { store.forgetIdentity(host) }, onRemove: { store.remove(host) }, - onBrowseLibrary: onBrowseLibrary) + onBrowseLibrary: onBrowseLibrary, + onWake: { + let macs = host.wakeMacs + let ip = host.address + DispatchQueue.global(qos: .userInitiated).async { + PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip) + } + }) } private var discoveredSection: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift index b2ebccb..28bfd32 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift @@ -86,6 +86,9 @@ struct HostCardView: View { let onRemove: () -> Void /// Open the experimental library browser — nil (no menu item) unless the feature flag is on. var onBrowseLibrary: (() -> Void)? = nil + /// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored + /// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it"). + var onWake: (() -> Void)? = nil var body: some View { let m = CardMetrics.current @@ -138,6 +141,9 @@ struct HostCardView: View { if let onBrowseLibrary { Button("Browse Library…", action: onBrowseLibrary) } + if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake { + Button("Wake Host", systemImage: "power", action: onWake) + } if host.pinnedSHA256 != nil { // Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via // PIN (unless the host advertises pair=optional). Wording reflects that. diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 9701398..81778d6 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -26,9 +26,16 @@ struct StoredHost: Identifiable, Codable, Hashable { /// decode: synthesized Decodable ignores property defaults but treats a missing Optional as /// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.) var mgmtPort: UInt16? + /// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`. + /// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the + /// client can send a magic packet to wake the host later (when it's asleep and no longer + /// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned. + var macAddresses: [String]? var displayName: String { name.isEmpty ? address : name } var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort } + /// Wake-capable, in a form the wake helper accepts (empty when none learned yet). + var wakeMacs: [String] { macAddresses ?? [] } } extension StoredHost { @@ -101,6 +108,16 @@ final class HostStore: ObservableObject { hosts[i].pinnedSHA256 = fingerprint } + /// Learn/refresh this host's Wake-on-LAN MAC(s) from its live advert (called while the host is + /// awake, so the client can wake it once it sleeps). No-op when unchanged, so it doesn't churn + /// UserDefaults on every discovery tick. + func updateMacs(_ hostID: UUID, macs: [String]) { + guard !macs.isEmpty, + let i = hosts.firstIndex(where: { $0.id == hostID }), + hosts[i].macAddresses != macs else { return } + hosts[i].macAddresses = macs + } + /// Drop the pinned identity (e.g. after a legitimate host reinstall). This does NOT downgrade /// to TOFU: the next connect re-pairs via the PIN ceremony, unless the host advertises /// `pair=optional` (the only case the connect path still offers the trust prompt). diff --git a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift index 71f4dd4..4db7e91 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift @@ -31,6 +31,12 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable { /// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional: /// pairing is mandatory unless this is true (the policy authority is the host's advert). public let allowsTofu: Bool + /// Wake-on-LAN MAC address(es) the host advertised (mDNS `mac` TXT, comma-separated + /// `aa:bb:cc:dd:ee:ff`, routed NIC first). Empty when not advertised. A client persists these + /// onto the saved host so it can wake it after it sleeps; advisory/unauthenticated (a wrong + /// value only makes a wake fail — the magic packet is inert and the fingerprint still gates + /// the connection). + public let macAddresses: [String] } @MainActor @@ -111,10 +117,15 @@ public final class HostDiscovery: ObservableObject { var fp: String? var pair: String? var id: String? + var macs: [String] = [] if case let .bonjour(txt) = result.metadata { fp = Self.entry(txt, "fp") pair = Self.entry(txt, "pair") id = Self.entry(txt, "id") + macs = (Self.entry(txt, "mac") ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } } let conn = NWConnection(to: result.endpoint, using: .udp) connections[key] = conn @@ -129,7 +140,7 @@ public final class HostDiscovery: ObservableObject { id: (id?.isEmpty == false) ? id! : name, name: name, host: address, port: port.rawValue, fingerprintHex: fp, requiresPairing: pair == "required", - allowsTofu: pair == "optional") + allowsTofu: pair == "optional", macAddresses: macs) self.publish() } conn.cancel() diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 6423a3f..edc725f 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -67,6 +67,53 @@ func withOptionalCString(_ s: String?, _ body: (UnsafePointer?) -> R) return s.withCString { body($0) } } +public extension PunktfunkConnection { + /// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always + /// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed + /// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's + /// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is + /// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action. + /// The MAC-learning path stays active on every platform, so flipping this on once the + /// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to + /// `true` for iOS/tvOS too (and uncomment the entitlement). + static var wakeOnLANAvailable: Bool { + #if os(macOS) + return true + #else + return false + #endif + } + + /// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s) + /// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are + /// skipped. `lastKnownIP`, when set, is additionally unicast. The core broadcasts to every + /// interface's subnet-directed broadcast + 255.255.255.255 on ports 9/7, repeated. + /// + /// Returns true if at least one datagram went out. Does blocking sends — call OFF the main + /// thread. On iOS/tvOS this requires the `com.apple.developer.networking.multicast` entitlement + /// (broadcast is otherwise blocked by the OS); macOS needs only the existing network entitlements. + @discardableResult + static func wakeOnLAN(macs: [String], lastKnownIP: String? = nil) -> Bool { + var bytes: [UInt8] = [] + var count = 0 + for mac in macs { + let parts = mac.split(separator: ":") + guard parts.count == 6 else { continue } + let octets = parts.compactMap { UInt8($0, radix: 16) } + guard octets.count == 6 else { continue } + bytes.append(contentsOf: octets) + count += 1 + } + guard count > 0 else { return false } + let rc: Int32 = bytes.withUnsafeBufferPointer { buf in + withOptionalCString(lastKnownIP) { ip in + punktfunk_wake_on_lan(buf.baseAddress, UInt(count), ip) + } + } + return rc == statusOK + } +} + public final class PunktfunkConnection { private var handle: OpaquePointer? /// Set by close() before it contends for the plane locks: the pullers see it at their diff --git a/clients/decky/main.py b/clients/decky/main.py index eea131e..2569bf0 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -489,6 +489,40 @@ class Plugin: reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1] return {"ok": False, "error": reason} + async def wake(self, host: str, port: int = 9777) -> dict: + """Send a Wake-on-LAN magic packet to a saved host via the flatpak client's headless + ``--wake`` mode, so a sleeping host is up by the time the stream ``--connect`` runs. + + The MAC comes from the flatpak client's OWN known-hosts store (learned from the host's + mDNS ``mac`` TXT while it was online) — no MAC handling here — so this is a no-op if none + has been learned yet. Fire it just before launching a stream; it's fast and best-effort. + Returns ``{ok, error?}`` (``ok: False`` when no MAC is known / flatpak missing). + """ + flatpak = _flatpak() + if not flatpak: + return {"ok": False, "error": "flatpak-not-found"} + argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--wake", f"{host}:{port}"] + decky.logger.info("wake: %s:%s", host, port) + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=_flatpak_env(), + ) + _, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0) + except asyncio.TimeoutError: + return {"ok": False, "error": "wake timed out"} + except Exception as exc: # noqa: BLE001 + decky.logger.exception("wake failed to launch") + return {"ok": False, "error": str(exc)} + if proc.returncode == 0: + return {"ok": True} + reason = (stderr.decode(errors="replace").strip().splitlines() or + ["no MAC known for this host yet"])[-1] + decky.logger.info("wake skipped (rc=%s): %s", proc.returncode, reason) + return {"ok": False, "error": reason} + async def library(self, host: str, mgmt_port: int = 0, fp: str = "") -> dict: """Fetch a paired host's game library via the flatpak client's headless ``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport — diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 029f20b..8db2084 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -122,6 +122,12 @@ export const setSettings = callable<[settings: StreamSettings], { ok: boolean }> "set_settings", ); export const killStream = callable<[], { ok: boolean }>("kill_stream"); +// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is +// up by the time the stream connects. The MAC is looked up from the flatpak client's own +// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching. +export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>( + "wake", +); export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update"); // Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`). export const updateClient = callable< diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 0acaf4e..0b2663e 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -8,7 +8,7 @@ // and start it with RunGame. The wrapper then execs // `flatpak run io.unom.Punktfunk --connect ` as a reaper descendant. -import { runnerInfo, shortcutArt } from "./backend"; +import { runnerInfo, shortcutArt, wake } from "./backend"; // SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed // by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the @@ -219,6 +219,11 @@ export async function launchStream( port: number, opts: LaunchOpts = {}, ): Promise { + // Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now + // so it races with the shortcut setup (near-zero added latency), and awaited just before RunGame. + // Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is + // known), and the connect that follows has its own retry window, so a failure never blocks launch. + const waking = wake(host, port).catch(() => ({ ok: false })); const { appId, runner } = await ensureShortcut(); // Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user // disables Steam Input manually — see the Settings instruction). @@ -240,6 +245,7 @@ export async function launchStream( // KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper // script rides behind it as an argument and reads PF_* from the environment. SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`); + await waking; // ensure the magic packet is out before the connect attempt SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); } diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index cbde161..b8e5d4a 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -142,6 +142,11 @@ pub fn run() -> glib::ExitCode { if let Some(target) = crate::cli::arg_value("--library") { return crate::cli::headless_library(&target); } + // Headless Wake-on-LAN (no GTK window): `--wake host[:port]`. The Decky wrapper calls this + // before the stream launch so a sleeping host is up by the time `--connect` runs. + if crate::cli::arg_value("--wake").is_some() { + return crate::cli::cli_wake(); + } let mut builder = adw::Application::builder().application_id(APP_ID); // Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each // launch its own primary instance instead of forwarding to a still-registered name. diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index e9ff1c0..f608261 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -101,6 +101,14 @@ pub fn cli_connect_request() -> Option { eprintln!("--connect: unparsable port in '{target}', using default 9777"); 9777 }); + // Pull the wake MAC(s) from the store (learned from the host's mDNS `mac` TXT while it was + // online) so a `--connect` to a known host can still be woken if we add that later. + let mac = crate::trust::KnownHosts::load() + .hosts + .iter() + .find(|h| h.addr == addr && h.port == port) + .map(|h| h.mac.clone()) + .unwrap_or_default(); Some(ConnectRequest { name: addr.clone(), addr, @@ -108,9 +116,39 @@ pub fn cli_connect_request() -> Option { fp_hex: None, pair_optional: false, launch: arg_value("--launch").map(|id| (id.clone(), id)), + mac, }) } +/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, without +/// opening a window. The Decky wrapper calls this before launching the stream so a sleeping host +/// is up by the time `--connect` runs. The MAC comes from the known-hosts store (learned from the +/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet. +pub fn cli_wake() -> glib::ExitCode { + let Some(target) = arg_value("--wake") else { + eprintln!("--wake requires host[:port]"); + return glib::ExitCode::FAILURE; + }; + let (addr, port) = parse_host_port(&target); + let port = port.unwrap_or(9777); + let mac = crate::trust::KnownHosts::load() + .hosts + .iter() + .find(|h| h.addr == addr && h.port == port) + .map(|h| h.mac.clone()) + .unwrap_or_default(); + if mac.is_empty() { + eprintln!( + "--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \ + advertised MAC is learned" + ); + return glib::ExitCode::FAILURE; + } + crate::wol::wake(&mac, addr.parse().ok()); + println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len()); + glib::ExitCode::SUCCESS +} + /// `--browse host[:port]` — open the gamepad library launcher for that host instead of /// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must /// already be paired: the stored pin is what lets the launcher fetch the library and @@ -138,6 +176,7 @@ pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> { fp_hex: k.map(|k| k.fp_hex.clone()), pair_optional: false, launch: None, + mac: k.map(|k| k.mac.clone()).unwrap_or_default(), }, k.is_some_and(|k| k.paired), mgmt, @@ -210,6 +249,7 @@ pub fn run_shot(app: Rc, scene: &str) { ), pair_optional: true, launch: None, + mac: Vec::new(), }; let mock_advert = |key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost { @@ -221,6 +261,7 @@ pub fn run_shot(app: Rc, scene: &str) { fp_hex: fp.to_string(), pair: "required".to_string(), mgmt_port: None, + mac: Vec::new(), }; // What the self-capture renders: the main window, except for scenes that open their diff --git a/clients/linux/src/discovery.rs b/clients/linux/src/discovery.rs index 8cbd5dd..47b5c9e 100644 --- a/clients/linux/src/discovery.rs +++ b/clients/linux/src/discovery.rs @@ -22,6 +22,9 @@ pub struct DiscoveredHost { /// `None` when not advertised (older host / standalone `punktfunk1-host`); the /// library client then falls back to the well-known default. pub mgmt_port: Option, + /// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the + /// hosts page persists onto the matching saved host so it can wake it later. Empty if absent. + pub mac: Vec, } /// One discovery update for the UI's advert map. @@ -81,6 +84,11 @@ pub fn browse() -> async_channel::Receiver { fp_hex: val("fp"), pair: val("pair"), mgmt_port: val("mgmt").parse().ok(), + mac: val("mac") + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), }) } ServiceEvent::ServiceRemoved(_ty, fullname) => { diff --git a/clients/linux/src/main.rs b/clients/linux/src/main.rs index 578f408..57fd694 100644 --- a/clients/linux/src/main.rs +++ b/clients/linux/src/main.rs @@ -40,6 +40,8 @@ mod ui_trust; #[cfg(target_os = "linux")] mod video; +mod wol; + #[cfg(target_os = "linux")] fn main() -> gtk::glib::ExitCode { app::run() diff --git a/clients/linux/src/trust.rs b/clients/linux/src/trust.rs index a680ef0..d8b1e00 100644 --- a/clients/linux/src/trust.rs +++ b/clients/linux/src/trust.rs @@ -60,6 +60,11 @@ pub struct KnownHost { /// most-recent card with the accent bar. `default` so pre-existing stores load. #[serde(default)] pub last_used: Option, + /// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it + /// was online, so we can wake it once it sleeps and stops advertising. `default` so + /// pre-existing stores load; empty until first learned. + #[serde(default)] + pub mac: Vec, } #[derive(Default, Serialize, Deserialize)] @@ -115,6 +120,10 @@ impl KnownHosts { if entry.last_used.is_some() { h.last_used = entry.last_used; } + // Likewise a trust-decision upsert (which carries no MAC) must not wipe learned MACs. + if !entry.mac.is_empty() { + h.mac = entry.mac; + } } else { self.hosts.push(entry); } @@ -132,10 +141,31 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo fp_hex: fp_hex.to_string(), paired, last_used: None, + mac: Vec::new(), }); let _ = known.save(); } +/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host +/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so +/// the hosts page can call it on every discovery tick without churning the store. +pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) { + if mac.is_empty() { + return; + } + let mut known = KnownHosts::load(); + let Some(h) = known.hosts.iter_mut().find(|h| { + (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port) + }) else { + return; + }; + if h.mac == mac { + return; + } + h.mac = mac.to_vec(); + let _ = known.save(); +} + /// Stamp "now" as this host's last successful connect (drives the hosts page's /// most-recent accent). No-op when the fingerprint isn't stored. pub fn touch_last_used(fp_hex: &str) { diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 5d6bfc7..54f98aa 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -29,6 +29,9 @@ pub struct ConnectRequest { /// `("steam:570", "Dota 2")`) — set by the library page's card activation; the id /// rides the Hello and the name titles the stream page. `None` = plain desktop session. pub launch: Option<(String, String)>, + /// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert). Used to send a + /// magic packet before connecting to an offline host. Empty when none is known. + pub mac: Vec, } impl ConnectRequest { @@ -314,6 +317,11 @@ fn rebuild(state: &Rc) { state.saved_flow.remove_all(); for k in &known.hosts { let online = adverts.values().any(|a| matches(k, a)); + // Learn this host's wake MAC(s) from its live advert while it's online, so we can wake it + // once it sleeps and stops advertising (no-op / no disk write when unchanged). + if let Some(a) = adverts.values().find(|a| matches(k, a) && !a.mac.is_empty()) { + crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac); + } let recent = most_recent.as_deref() == Some(k.fp_hex.as_str()); state .saved_flow @@ -421,6 +429,7 @@ fn saved_card( // connect; TOFU eligibility is irrelevant. pair_optional: false, launch: None, + mac: k.mac.clone(), }; // Presence pip + spelled-out state, then the trust pill. @@ -492,11 +501,21 @@ fn saved_card( Box::new(move || forget_dialog(&state, &fp, &name)), ); } + { + // Explicit "just wake it" (the tap-to-connect already auto-wakes an offline host). + let mac = k.mac.clone(); + let addr = k.addr.clone(); + add("wake", Box::new(move || crate::wol::wake(&mac, addr.parse().ok()))); + } overlay.insert_action_group("card", Some(&actions)); let menu = gio::Menu::new(); menu.append(Some("Pair with PIN…"), Some("card.pair")); menu.append(Some("Test network speed…"), Some("card.speed")); + // Offer an explicit wake only when the host is offline and we actually have a MAC to target. + if !online && !k.mac.is_empty() { + menu.append(Some("Wake host"), Some("card.wake")); + } // Experimental (Preferences gate, Apple parity): browse the host's game library. The // item is offered on every saved card — an unpaired host answers with the friendly // "not paired" error state rather than the entry hiding itself. @@ -521,7 +540,16 @@ fn saved_card( overlay.add_controller(right_click); let on_connect = state.cbs.on_connect.clone(); - child.connect_activate(move |_| on_connect(req.clone())); + // Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, fire a + // magic packet before connecting — the connect's own retry/timeout gives a woken host time to + // come up. A host that's genuinely off/unreachable then fails the connect as before. + let wake_first = !online && !req.mac.is_empty(); + child.connect_activate(move |_| { + if wake_first { + crate::wol::wake(&req.mac, req.addr.parse().ok()); + } + on_connect(req.clone()); + }); child } @@ -539,6 +567,7 @@ fn discovered_card( // required/empty means mandatory PIN. pair_optional: a.pair == "optional", launch: None, + mac: a.mac.clone(), }; let status = gtk::Box::new(gtk::Orientation::Horizontal, 6); @@ -674,6 +703,7 @@ fn add_host_dialog(state: &Rc) { // Manual entry carries no advertised policy — never eligible for TOFU. pair_optional: false, launch: None, + mac: Vec::new(), }); }); } diff --git a/clients/linux/src/wol.rs b/clients/linux/src/wol.rs new file mode 100644 index 0000000..25db1e1 --- /dev/null +++ b/clients/linux/src/wol.rs @@ -0,0 +1,24 @@ +//! Client-side Wake-on-LAN: parse stored MAC strings and hand them to the shared core sender +//! (`punktfunk_core::wol`). A sleeping host has no ARP entry, so the broadcast the core sends is +//! what actually wakes it; this is called just before connecting to an offline saved host, and +//! from the explicit "Wake host" menu item / `--wake` CLI mode. + +use std::net::Ipv4Addr; + +/// Fire a Wake-on-LAN magic packet at `macs` (each `aa:bb:cc:dd:ee:ff`), also unicasting +/// `last_ip` when given. Best-effort — logs the outcome and never blocks the caller meaningfully +/// (the core sends a short burst of datagrams and returns). +pub fn wake(macs: &[String], last_ip: Option) { + let parsed: Vec<[u8; 6]> = macs + .iter() + .filter_map(|s| punktfunk_core::wol::parse_mac(s)) + .collect(); + if parsed.is_empty() { + tracing::warn!("wake requested but no valid MAC is known for this host"); + return; + } + match punktfunk_core::wol::send_magic_packet(&parsed, last_ip) { + Ok(()) => tracing::info!(count = parsed.len(), "sent Wake-on-LAN magic packet"), + Err(e) => tracing::warn!(error = %e, "Wake-on-LAN send failed"), + } +} diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index fbe56fc..4dd966a 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -245,6 +245,7 @@ fn connect_with( port: target.port, fp_hex: trust::hex(&fingerprint), paired: persist_paired, + mac: target.mac.clone(), }); let _ = k.save(); } diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index 7ae92d1..df93e99 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -13,6 +13,7 @@ use windows_reactor::*; /// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text. const MENU_CONNECT: &str = "Connect"; const MENU_SPEED: &str = "Test network speed\u{2026}"; +const MENU_WAKE: &str = "Wake host"; const MENU_RENAME: &str = "Rename\u{2026}"; const MENU_FORGET: &str = "Forget\u{2026}"; @@ -318,10 +319,19 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { port: k.port, fp_hex: Some(k.fp_hex.clone()), pair_optional: false, + mac: k.mac.clone(), }; let online = hosts .iter() .any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)); + // Learn this host's wake MAC(s) from its live advert while it's online, so we can wake + // it once it sleeps (no-op / no disk write when unchanged). + if let Some(a) = hosts.iter().find(|h| { + (h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.mac.is_empty() + }) { + crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac); + } + let can_wake = !online && !k.mac.is_empty(); let menu = { let (svc, target) = (props.svc.clone(), target.clone()); let (sf, sr) = (set_forget.clone(), set_rename.clone()); @@ -331,17 +341,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { .subtle() .tooltip("More options") .automation_name("More options") - .menu_flyout(vec![ - menu_item(MENU_CONNECT), - menu_item(MENU_SPEED), - menu_item(MENU_RENAME), - menu_separator(), - menu_item(MENU_FORGET), - ]) + .menu_flyout({ + let mut items = vec![menu_item(MENU_CONNECT), menu_item(MENU_SPEED)]; + // Offer an explicit wake only when the host is offline and we have a MAC. + if can_wake { + items.push(menu_item(MENU_WAKE)); + } + items.push(menu_item(MENU_RENAME)); + items.push(menu_separator()); + items.push(menu_item(MENU_FORGET)); + items + }) .on_item_clicked(move |item: String| match item.as_str() { MENU_CONNECT => { initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) } + MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()), MENU_SPEED => { *svc.ctx.shared.target.lock().unwrap() = target.clone(); // New run: invalidate any still-in-flight probe, reset the screen. @@ -369,7 +384,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { if k.paired { Pill::Good } else { Pill::Info }, ), Some(menu), - Some(Box::new(move || initiate(&ctx2, target.clone(), &ss, &st))), + Some(Box::new(move || { + // Auto-wake an offline saved host before connecting; the connect's own + // retry/timeout gives a woken host time to come up. + if can_wake { + crate::wol::wake(&target.mac, target.addr.parse().ok()); + } + initiate(&ctx2, target.clone(), &ss, &st) + })), )); } body.push(tile_grid(tiles, cols)); @@ -406,6 +428,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { port: h.port, fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()), pair_optional: h.pair == "optional", + mac: h.mac.clone(), }; let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); let (badge, kind) = if h.pair == "required" { @@ -486,6 +509,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { port, fp_hex: None, pair_optional: false, + mac: Vec::new(), }, &ss, &st, diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 33acf50..1346c98 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -68,6 +68,9 @@ pub(crate) struct Target { pub(crate) port: u16, pub(crate) fp_hex: Option, pub(crate) pair_optional: bool, + /// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a + /// magic packet before connecting to an offline host. Empty when none is known. + pub(crate) mac: Vec, } /// Stable app services handed to the page components as props. Each routed screen that uses diff --git a/clients/windows/src/app/pair.rs b/clients/windows/src/app/pair.rs index 52efbb2..8213dc1 100644 --- a/clients/windows/src/app/pair.rs +++ b/clients/windows/src/app/pair.rs @@ -50,6 +50,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { port: target3.port, fp_hex: trust::hex(&fp), paired: true, + mac: target3.mac.clone(), }); let _ = k.save(); connect(&ctx3, &target3, Some(fp), &ss, &st); diff --git a/clients/windows/src/discovery.rs b/clients/windows/src/discovery.rs index e8d0a6e..784bc66 100644 --- a/clients/windows/src/discovery.rs +++ b/clients/windows/src/discovery.rs @@ -15,6 +15,9 @@ pub struct DiscoveredHost { pub fp_hex: String, /// Pairing requirement: `"required"` or `"optional"`. pub pair: String, + /// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the + /// hosts page persists onto the matching saved host so it can wake it later. Empty if absent. + pub mac: Vec, } /// Browse continuously for the app's lifetime. The thread exits when the receiver is @@ -63,6 +66,11 @@ pub fn browse() -> async_channel::Receiver { port: info.get_port(), fp_hex: val("fp"), pair: val("pair"), + mac: val("mac") + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), }; if tx.send_blocking(host).is_err() { break; // UI gone — stop browsing diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 4c1c275..a63d08f 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -43,6 +43,8 @@ mod trust; #[cfg(windows)] mod video; +mod wol; + #[cfg(windows)] fn main() { // With #![windows_subsystem = "windows"] the process starts with no console, so the GUI/MSIX @@ -187,6 +189,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) { port, fp_hex: trust::hex(&fp), paired: true, + mac: Vec::new(), }); let _ = k.save(); tracing::info!(fp = %trust::hex(&fp), "paired"); diff --git a/clients/windows/src/trust.rs b/clients/windows/src/trust.rs index aa32de1..d16a3a4 100644 --- a/clients/windows/src/trust.rs +++ b/clients/windows/src/trust.rs @@ -57,6 +57,11 @@ pub struct KnownHost { pub fp_hex: String, /// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use). pub paired: bool, + /// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was + /// online, so we can wake it once it sleeps. `default` so pre-existing stores load; empty until + /// first learned. + #[serde(default)] + pub mac: Vec, } #[derive(Default, Serialize, Deserialize)] @@ -106,12 +111,36 @@ impl KnownHosts { h.addr = entry.addr; h.port = entry.port; h.paired |= entry.paired; + // A trust-decision upsert (which carries no MAC) must not wipe learned MACs. + if !entry.mac.is_empty() { + h.mac = entry.mac; + } } else { self.hosts.push(entry); } } } +/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host is +/// online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so the +/// hosts page can call it on every discovery tick without churning the store. +pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) { + if mac.is_empty() { + return; + } + let mut known = KnownHosts::load(); + let Some(h) = known.hosts.iter_mut().find(|h| { + (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port) + }) else { + return; + }; + if h.mac == mac { + return; + } + h.mac = mac.to_vec(); + let _ = known.save(); +} + /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// stays readable; parsed with `*Pref::from_name` at connect time. #[derive(Clone, Serialize, Deserialize)] diff --git a/clients/windows/src/wol.rs b/clients/windows/src/wol.rs new file mode 100644 index 0000000..ee1047e --- /dev/null +++ b/clients/windows/src/wol.rs @@ -0,0 +1,24 @@ +//! Client-side Wake-on-LAN: parse stored MAC strings and hand them to the shared core sender +//! (`punktfunk_core::wol`). A sleeping host has no ARP entry, so the broadcast the core sends is +//! what actually wakes it; this is called just before connecting to an offline saved host and +//! from the explicit "Wake host" menu item. + +use std::net::Ipv4Addr; + +/// Fire a Wake-on-LAN magic packet at `macs` (each `aa:bb:cc:dd:ee:ff`), also unicasting +/// `last_ip` when given. Best-effort — logs the outcome and returns promptly (the core sends a +/// short burst of datagrams). +pub fn wake(macs: &[String], last_ip: Option) { + let parsed: Vec<[u8; 6]> = macs + .iter() + .filter_map(|s| punktfunk_core::wol::parse_mac(s)) + .collect(); + if parsed.is_empty() { + tracing::warn!("wake requested but no valid MAC is known for this host"); + return; + } + match punktfunk_core::wol::send_magic_packet(&parsed, last_ip) { + Ok(()) => tracing::info!(count = parsed.len(), "sent Wake-on-LAN magic packet"), + Err(e) => tracing::warn!(error = %e, "Wake-on-LAN send failed"), + } +}