diff --git a/Cargo.lock b/Cargo.lock index 50a8338..9cd2801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1952,6 +1952,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "if-addrs" version = "0.15.0" @@ -2195,7 +2205,7 @@ dependencies = [ "cookie-factory", "libc", "libspa-sys", - "nix", + "nix 0.30.1", "nom 8.0.0", "system-deps", ] @@ -2262,6 +2272,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2285,7 +2305,7 @@ checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e" dependencies = [ "fastrand", "flume", - "if-addrs", + "if-addrs 0.15.0", "log", "mio", "socket-pktinfo", @@ -2383,6 +2403,19 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" @@ -2742,7 +2775,7 @@ dependencies = [ "libc", "libspa", "libspa-sys", - "nix", + "nix 0.30.1", "once_cell", "pipewire-sys", "thiserror 2.0.18", @@ -2943,6 +2976,7 @@ dependencies = [ "criterion", "fec-rs", "hmac", + "if-addrs 0.13.4", "libc", "opus", "proptest", @@ -2983,10 +3017,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "if-addrs 0.13.4", "khronos-egl", "libc", "libloading", "log", + "mac_address", "mdns-sd", "nvidia-video-codec-sdk", "openh264", @@ -4766,6 +4802,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4775,6 +4827,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" 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..1eec536 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,14 @@ 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/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 97b6cff..c1d9171 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -365,6 +365,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; + INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; @@ -399,6 +400,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; + INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; 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 92823be..4f4fd02 100644 --- a/clients/linux/src/main.rs +++ b/clients/linux/src/main.rs @@ -42,6 +42,8 @@ mod video; #[cfg(target_os = "linux")] mod video_gl; +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..86739ac 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,33 @@ 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..7e62dff 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,14 @@ 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 +432,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 +504,24 @@ 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 +546,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 +573,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 +709,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..a7104c6 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,20 @@ 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 +342,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 +385,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 +429,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 +510,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..a1ed461 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,38 @@ 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"), + } +} diff --git a/crates/punktfunk-core/Cargo.toml b/crates/punktfunk-core/Cargo.toml index e537099..fa56ce5 100644 --- a/crates/punktfunk-core/Cargo.toml +++ b/crates/punktfunk-core/Cargo.toml @@ -38,6 +38,10 @@ thiserror = "2" tracing = { version = "0.1", default-features = false, features = ["std"] } rand = "0.9" zeroize = "1" +# Interface enumeration for Wake-on-LAN: computes each NIC's subnet-directed broadcast so a +# magic packet reaches the host's L2 segment on multi-homed clients (VPN/docker/multiple LANs), +# not just the default route. Tiny, cross-platform (getifaddrs / GetAdaptersAddresses), no cmake. +if-addrs = "0.13" quinn = { version = "0.11", optional = true } rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index ff3fdfc..111b730 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -183,6 +183,60 @@ pub extern "C" fn punktfunk_abi_version() -> u32 { crate::ABI_VERSION } +/// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s). +/// +/// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) — +/// a host may report several NICs; all are woken. `last_known_ip`, if non-NULL, is an IPv4 +/// dotted-quad string additionally targeted by unicast (pass NULL to skip). The packet is +/// broadcast to every local interface's subnet-directed broadcast and to `255.255.255.255` on +/// ports 9 and 7. This does NOT require an open connection and is not part of the QUIC surface. +/// +/// Returns `Ok` if at least one datagram was sent. Call off the UI thread. +/// +/// # Safety +/// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL, +/// must be a NUL-terminated string. +#[no_mangle] +pub unsafe extern "C" fn punktfunk_wake_on_lan( + macs: *const u8, + mac_count: usize, + last_known_ip: *const c_char, +) -> PunktfunkStatus { + guard(|| { + if macs.is_null() { + return PunktfunkStatus::NullPointer; + } + if mac_count == 0 { + return PunktfunkStatus::InvalidArg; + } + let bytes = unsafe { std::slice::from_raw_parts(macs, mac_count * 6) }; + let mac_vec: Vec = bytes + .chunks_exact(6) + .map(|c| { + let mut m = [0u8; 6]; + m.copy_from_slice(c); + m + }) + .collect(); + let ip = if last_known_ip.is_null() { + None + } else { + match unsafe { CStr::from_ptr(last_known_ip) } + .to_str() + .ok() + .and_then(|s| s.parse::().ok()) + { + Some(ip) => Some(ip), + None => return PunktfunkStatus::InvalidArg, + } + }; + match crate::wol::send_magic_packet(&mac_vec, ip) { + Ok(()) => PunktfunkStatus::Ok, + Err(_) => PunktfunkStatus::Io, + } + }) +} + /// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings). /// Returns NULL on error. /// diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 5cabee2..ec991dc 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -39,6 +39,7 @@ pub mod quic; pub mod session; pub mod stats; pub mod transport; +pub mod wol; pub use config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role}; pub use error::{PunktfunkError, PunktfunkStatus, Result}; @@ -50,4 +51,6 @@ pub use stats::Stats; /// /// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities); /// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. -pub const ABI_VERSION: u32 = 2; +/// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach +/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). +pub const ABI_VERSION: u32 = 3; diff --git a/crates/punktfunk-core/src/wol.rs b/crates/punktfunk-core/src/wol.rs new file mode 100644 index 0000000..b2d6a3d --- /dev/null +++ b/crates/punktfunk-core/src/wol.rs @@ -0,0 +1,194 @@ +//! Wake-on-LAN: magic-packet builder + broadcast sender. +//! +//! Runtime-free by design — a magic packet is one fire-and-forget UDP datagram, so this needs +//! neither the `quic` feature nor an async runtime and links into every client (including the +//! QUIC-less builds). The Rust clients (linux/windows/android) call these `pub fn`s directly; +//! Swift/iOS reach them through the `punktfunk_wake_on_lan` C-ABI wrapper in [`crate::abi`]. +//! +//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast +//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each +//! known host MAC we send the 102-byte packet to: +//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's +//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and +//! * the **limited broadcast** `255.255.255.255`, and +//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the +//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast), +//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss. + +use std::io; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket}; + +/// A MAC address (EUI-48), the 6 bytes a magic packet targets. +pub type Mac = [u8; 6]; + +/// Conventional Wake-on-LAN UDP ports. 9 (discard) is by far the most common; 7 (echo) is a +/// historical alternative some NICs also listen on. Sending to both is free insurance. +const WOL_PORTS: [u16; 2] = [9, 7]; + +/// Times each packet is re-sent per call. UDP is lossy and this is fire-and-forget; a small +/// burst costs microseconds and materially improves the odds a waking NIC catches one. The +/// caller's connect-retry loop provides the longer-spaced re-attempts. +const BURST: usize = 3; + +/// Parse a MAC string — `aa:bb:cc:dd:ee:ff` or `aa-bb-...`, case-insensitive — into 6 bytes. +/// Returns `None` for anything that isn't exactly six hex octets. Shared by the Rust clients +/// (linux/windows) so MAC parsing lives in one place; the Swift/Apple client parses its own. +pub fn parse_mac(s: &str) -> Option { + let mut m = [0u8; 6]; + let mut n = 0; + for part in s.split(|c| c == ':' || c == '-') { + if n == 6 { + return None; // too many octets + } + m[n] = u8::from_str_radix(part.trim(), 16).ok()?; + n += 1; + } + (n == 6).then_some(m) +} + +/// The 102-byte magic packet for `mac`: 6×`0xFF` followed by the MAC repeated 16 times. +pub fn build_magic_packet(mac: Mac) -> [u8; 102] { + let mut pkt = [0xFFu8; 102]; + for i in 0..16 { + let off = 6 + i * 6; + pkt[off..off + 6].copy_from_slice(&mac); + } + pkt +} + +/// Broadcast a wake for every MAC in `macs`. `last_known_ip`, when set, is additionally +/// targeted by unicast. +/// +/// Returns `Ok` if at least one datagram was sent, so a single unreachable target (e.g. a +/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket +/// could be opened or nothing could be sent at all. +pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option) -> io::Result<()> { + if macs.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "no MAC addresses", + )); + } + + // Build the target IP set: each interface's directed broadcast, the limited broadcast, and + // the optional last-known unicast. Dedup so a single-NIC client doesn't send twice. + let mut targets = broadcast_addrs(); + targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255 + if let Some(ip) = last_known_ip { + targets.push(ip); + } + targets.sort_unstable(); + targets.dedup(); + + // One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the + // matching NIC via the routing table; the limited broadcast leaves via the default route. + let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?; + sock.set_broadcast(true)?; + + let mut sent_any = false; + for _ in 0..BURST { + for mac in macs { + let pkt = build_magic_packet(*mac); + for ip in &targets { + for port in WOL_PORTS { + let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port)); + if sock.send_to(&pkt, dst).is_ok() { + sent_any = true; + } + } + } + } + } + + if sent_any { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + "no magic packet could be sent", + )) + } +} + +/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`, +/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing +/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires. +fn broadcast_addrs() -> Vec { + let mut out = Vec::new(); + let ifaces = match if_addrs::get_if_addrs() { + Ok(i) => i, + Err(_) => return out, + }; + for iface in ifaces { + if iface.is_loopback() { + continue; + } + if let if_addrs::IfAddr::V4(v4) = iface.addr { + let bcast = v4 + .broadcast + .unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask))); + // Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we + // already add unconditionally. + if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST { + out.push(bcast); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn magic_packet_layout() { + let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; + let pkt = build_magic_packet(mac); + assert_eq!(pkt.len(), 102); + // 6-byte 0xFF sync stream. + assert_eq!(&pkt[0..6], &[0xFF; 6]); + // MAC repeated exactly 16 times. + for i in 0..16 { + let off = 6 + i * 6; + assert_eq!(&pkt[off..off + 6], &mac, "repetition {i} mismatch"); + } + } + + #[test] + fn empty_macs_is_error() { + assert!(send_magic_packet(&[], None).is_err()); + } + + #[test] + fn parse_mac_forms() { + assert_eq!( + parse_mac("aa:bb:cc:dd:ee:ff"), + Some([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]) + ); + assert_eq!( + parse_mac("AA-BB-CC-DD-EE-FF"), + Some([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]) + ); + assert_eq!(parse_mac("01:02:03:04:05:06"), Some([1, 2, 3, 4, 5, 6])); + assert_eq!(parse_mac("aa:bb:cc:dd:ee"), None); // too few + assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff:00"), None); // too many + assert_eq!(parse_mac("zz:bb:cc:dd:ee:ff"), None); // non-hex + assert_eq!(parse_mac(""), None); + } + + #[test] + fn send_does_not_panic_with_a_mac() { + // Best-effort: binds a real socket and broadcasts on the loopback host. Must not panic + // and, on any machine with a usable network stack, should report success. + let _ = send_magic_packet(&[[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]], None); + } + + #[test] + fn broadcast_addrs_never_contains_limited_or_unspecified() { + for b in broadcast_addrs() { + assert_ne!(b, Ipv4Addr::BROADCAST); + assert!(!b.is_unspecified()); + } + } +} diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index bd0b62d..7160b0d 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -21,6 +21,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-log = "0.2" axum = "0.8" mdns-sd = "0.20" +# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record. +# `mac_address` reads a NIC's hardware address; `if-addrs` maps the routed IP to its interface name. +mac_address = "1" +if-addrs = "0.13" tokio = { version = "1", features = ["full"] } rsa = "0.9" sha2 = { version = "0.10", features = ["oid"] } diff --git a/crates/punktfunk-host/src/discovery.rs b/crates/punktfunk-host/src/discovery.rs index 844a12b..2799dde 100644 --- a/crates/punktfunk-host/src/discovery.rs +++ b/crates/punktfunk-host/src/discovery.rs @@ -15,6 +15,9 @@ //! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's //! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port. //! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`). +//! - `mac` — the host's wake-capable NIC MAC(s) (comma-separated, routed NIC first), which a client +//! persists so it can Wake-on-LAN this host after it sleeps. Advisory/unauthenticated (a wrong +//! MAC only makes a wake fail). Omitted when none can be read. use anyhow::{Context, Result}; use mdns_sd::{ServiceDaemon, ServiceInfo}; @@ -63,6 +66,18 @@ pub fn advertise_native( if let Some(mgmt) = mgmt_port { props.insert("mgmt".into(), mgmt.to_string()); } + // `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC + // first. A client persists these while the host is awake so it can send a Wake-on-LAN magic + // packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like + // the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert + // and the cert fingerprint still gates the actual connection. Omitted when none can be read. + let macs = crate::wol::wake_macs(ip); + if !macs.is_empty() { + props.insert("mac".into(), macs.join(",")); + } + // Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL + // silently fails. + crate::wol::warn_if_not_armed(ip); let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props) .context("build native mDNS ServiceInfo")?; daemon diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 39cac44..bb859fd 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -22,6 +22,7 @@ mod audio; mod capture; mod config; mod discovery; +mod wol; // Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]` // keeps the `crate::*` module names flat (every existing path is unchanged). #[cfg(target_os = "linux")] diff --git a/crates/punktfunk-host/src/wol.rs b/crates/punktfunk-host/src/wol.rs new file mode 100644 index 0000000..5a10c3b --- /dev/null +++ b/crates/punktfunk-host/src/wol.rs @@ -0,0 +1,114 @@ +//! Host-side Wake-on-LAN support. +//! +//! Two jobs, both best-effort (a failure here never affects streaming): +//! 1. [`wake_macs`] — report the host's wake-capable NIC MAC(s) so a client can persist them +//! (from the mDNS `mac` TXT record, [`crate::discovery`]) and wake this host later, once it's +//! asleep and no longer advertising. +//! 2. [`warn_if_not_armed`] — *detect & warn only* whether the NIC is actually armed to wake on a +//! magic packet. We never change NIC settings (that's the user's call); we just surface the +//! single most common reason WoL silently fails. + +use std::net::IpAddr; + +/// Upper bound on advertised MACs — keeps the mDNS TXT record small. A host has at most a couple +/// of wake-capable NICs; the routed one is always first. +const MAX_MACS: usize = 4; + +/// MAC(s) of the host's wake-capable NIC(s), lowercase `aa:bb:cc:dd:ee:ff`, with the NIC that +/// bears `primary_ip` (the address clients reach us on) FIRST, then other non-loopback NICs as +/// fallbacks. Best-effort — an empty list just means clients can't auto-wake (they fall back to +/// manual MAC entry). Deduped; all-zero MACs skipped; capped at [`MAX_MACS`]. +pub fn wake_macs(primary_ip: IpAddr) -> Vec { + let ifaces = if_addrs::get_if_addrs().unwrap_or_default(); + + // Interface names in priority order: the one holding `primary_ip` first, then every other + // non-loopback interface that has an IP, de-duplicated by name (an iface has one MAC but may + // appear once per address). + let mut names: Vec = Vec::new(); + if let Some(primary) = ifaces.iter().find(|i| i.ip() == primary_ip) { + names.push(primary.name.clone()); + } + for i in &ifaces { + if i.is_loopback() { + continue; + } + if !names.contains(&i.name) { + names.push(i.name.clone()); + } + } + + let mut out: Vec = Vec::new(); + for name in names { + let Ok(Some(mac)) = mac_address::mac_address_by_name(&name) else { + continue; + }; + let b = mac.bytes(); + if b == [0u8; 6] { + continue; // unset / virtual + } + let s = format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + b[0], b[1], b[2], b[3], b[4], b[5] + ); + if !out.contains(&s) { + out.push(s); + } + if out.len() >= MAX_MACS { + break; + } + } + out +} + +/// Log whether the host NIC bearing `primary_ip` is armed to wake on a magic packet. Detect & +/// warn only — never modifies settings. Linux-only (reads `ethtool `); a no-op elsewhere +/// and silent when it can't tell (no `ethtool`, insufficient privilege). +#[cfg(target_os = "linux")] +pub fn warn_if_not_armed(primary_ip: IpAddr) { + let ifaces = if_addrs::get_if_addrs().unwrap_or_default(); + let Some(iface) = ifaces + .iter() + .find(|i| i.ip() == primary_ip) + .map(|i| i.name.clone()) + else { + return; + }; + match ethtool_wol_has_magic(&iface) { + Some(true) => { + tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC") + } + Some(false) => tracing::warn!( + iface = %iface, + "Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. \ + Enable it with: sudo ethtool -s {iface} wol g (and turn on 'Wake on LAN'/'Wake on \ + PCIe' in BIOS). Wired Ethernet is required; Wi-Fi wake is unreliable.", + ), + None => {} // couldn't determine — stay quiet rather than cry wolf + } +} + +#[cfg(not(target_os = "linux"))] +pub fn warn_if_not_armed(_primary_ip: IpAddr) {} + +/// Parse `ethtool ` for the *current* Wake-on setting and report whether it includes `g` +/// (wake on MagicPacket). Returns `None` if ethtool is missing/failed or the field is absent. +#[cfg(target_os = "linux")] +fn ethtool_wol_has_magic(iface: &str) -> Option { + let out = std::process::Command::new("ethtool") + .arg(iface) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + let t = line.trim(); + // The current setting is "Wake-on: "; skip the "Supports Wake-on: ..." capability + // line. `g` = MagicPacket, `d` = disabled. + if let Some(flags) = t.strip_prefix("Wake-on:") { + return Some(flags.trim().contains('g')); + } + } + None +} diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 04796c2..7f1b76f 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -17,7 +17,9 @@ // // v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities); // added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. -#define ABI_VERSION 2 +// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach +// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). +#define ABI_VERSION 3 // `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid). #define PUNKTFUNK_HIDOUT_LED 1 @@ -804,6 +806,23 @@ extern "C" { // Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core. uint32_t punktfunk_abi_version(void); +// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s). +// +// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) — +// a host may report several NICs; all are woken. `last_known_ip`, if non-NULL, is an IPv4 +// dotted-quad string additionally targeted by unicast (pass NULL to skip). The packet is +// broadcast to every local interface's subnet-directed broadcast and to `255.255.255.255` on +// ports 9 and 7. This does NOT require an open connection and is not part of the QUIC surface. +// +// Returns `Ok` if at least one datagram was sent. Call off the UI thread. +// +// # Safety +// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL, +// must be a NUL-terminated string. +PunktfunkStatus punktfunk_wake_on_lan(const uint8_t *macs, + uintptr_t mac_count, + const char *last_known_ip); + // Create a session over a real UDP transport (`local`/`peer` are `host:port` strings). // Returns NULL on error. //