feat(clients): Wake-on-LAN in apple/linux/windows/android/decky
apple / swift (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 1m14s
ci / rust (push) Failing after 49s
ci / web (push) Successful in 52s
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:
2026-07-04 13:37:14 +02:00
parent 22c0d92f2e
commit e9c5030190
33 changed files with 558 additions and 24 deletions
+13 -6
View File
@@ -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");