feat(clients): Wake-on-LAN in apple/linux/windows/android/decky
apple / swift (push) Successful in 1m7s
ci / rust (push) Failing after 49s
ci / web (push) Successful in 52s
audit / cargo-audit (push) Successful in 1m14s
windows-host / package (push) Failing after 2m58s
ci / docs-site (push) Successful in 1m5s
android / android (push) Successful in 4m7s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 48s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 49s
ci / bench (push) Successful in 5m5s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
apple / screenshots (push) Has been cancelled
docker / deploy-docs (push) Successful in 19s
apple / swift (push) Successful in 1m7s
ci / rust (push) Failing after 49s
ci / web (push) Successful in 52s
audit / cargo-audit (push) Successful in 1m14s
windows-host / package (push) Failing after 2m58s
ci / docs-site (push) Successful in 1m5s
android / android (push) Successful in 4m7s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 48s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 49s
ci / bench (push) Successful in 5m5s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
apple / screenshots (push) Has been cancelled
docker / deploy-docs (push) Successful in 19s
Each client learns a host's MAC from the mDNS `mac` TXT while it's awake, persists it on the saved-host record, and — when reconnecting to an offline host — sends a magic packet before connecting, plus an explicit "Wake host" action. Apple wraps the C-ABI; linux/windows call the core fn directly (linux also gains a --wake CLI mode); android via a new nativeWakeOnLan JNI export (the mDNS browse record gains a 7th mac field); decky shells out to the linux client's --wake before launching the stream. iOS/tvOS need the managed com.apple.developer.networking.multicast entitlement (pending Apple approval), so the wake path + UI are gated off via PunktfunkConnection.wakeOnLANAvailable and the entitlement is commented out — keeping iOS/tvOS releasable. MAC-learning stays active on every platform so it lights up the moment it's ungated. macOS works today. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ClientIdentity?>(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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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.
|
||||
|
||||
+7
-3
@@ -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<String> = 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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> = 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<String>) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<Host> {
|
||||
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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<String>::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::<std::net::Ipv4Addr>().ok();
|
||||
match punktfunk_core::wol::send_magic_packet(&macs, ip) {
|
||||
Ok(()) => 1,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user