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 identityStore = remember { IdentityStore(context) }
|
||||||
val knownHostStore = remember { KnownHostStore(context) }
|
val knownHostStore = remember { KnownHostStore(context) }
|
||||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
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
|
// 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).
|
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
||||||
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
||||||
@@ -176,6 +195,14 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
}
|
}
|
||||||
connecting = true
|
connecting = true
|
||||||
status = "Connecting to $targetHost:$targetPort…"
|
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
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||||
@@ -359,6 +386,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
},
|
},
|
||||||
onRename = { renameTarget = kh },
|
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,
|
onConnect: () -> Unit,
|
||||||
onForget: (() -> Unit)?,
|
onForget: (() -> Unit)?,
|
||||||
onRename: (() -> Unit)? = null,
|
onRename: (() -> Unit)? = null,
|
||||||
|
onWake: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
// 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.
|
// 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)
|
StatusPill(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onForget != null || onRename != null) {
|
if (onForget != null || onRename != null || onWake != null) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||||
@@ -119,6 +120,15 @@ fun HostCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
if (onWake != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Wake host") },
|
||||||
|
onClick = {
|
||||||
|
menu = false
|
||||||
|
onWake()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
if (onRename != null) {
|
if (onRename != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Rename") },
|
text = { Text("Rename") },
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ object NativeBridge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
* 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.
|
* cheap (a lock + string build), safe to call on the main thread.
|
||||||
*/
|
*/
|
||||||
external fun nativeDiscoveryPoll(handle: Long): String
|
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`. */
|
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
||||||
external fun nativeDiscoveryStop(handle: Long)
|
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
|
* 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.
|
* 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 port: Int,
|
||||||
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
||||||
val pairingRequired: Boolean = false,
|
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). */
|
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||||
private const val FIELD_SEP = '\u001F'
|
private const val FIELD_SEP = '\u001F'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac`), or
|
||||||
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
* null if it's malformed. `mac` (7th field) is optional — an older host omits it. Pure —
|
||||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
* 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? {
|
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||||
val f = record.split(FIELD_SEP)
|
val f = record.split(FIELD_SEP)
|
||||||
@@ -40,6 +42,8 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
|||||||
port = port,
|
port = port,
|
||||||
fingerprint = f[4].ifBlank { null },
|
fingerprint = f[4].ifBlank { null },
|
||||||
pairingRequired = f[5] == "required",
|
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 name: String,
|
||||||
val fpHex: String,
|
val fpHex: String,
|
||||||
val paired: Boolean,
|
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("name", host.name)
|
||||||
.put("fp", host.fpHex.lowercase())
|
.put("fp", host.fpHex.lowercase())
|
||||||
.put("paired", host.paired)
|
.put("paired", host.paired)
|
||||||
|
.put("mac", host.mac.joinToString(","))
|
||||||
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
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). */
|
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
||||||
fun remove(address: String, port: Int) {
|
fun remove(address: String, port: Int) {
|
||||||
prefs.edit().remove(key(address, port)).apply()
|
prefs.edit().remove(key(address, port)).apply()
|
||||||
@@ -68,6 +86,7 @@ class KnownHostStore(context: Context) {
|
|||||||
name = j.getString("name"),
|
name = j.getString("name"),
|
||||||
fpHex = j.getString("fp"),
|
fpHex = j.getString("fp"),
|
||||||
paired = j.optBoolean("paired", false),
|
paired = j.optBoolean("paired", false),
|
||||||
|
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const PROTO: &str = "punktfunk/1";
|
|||||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||||
const FIELD_SEP: char = '\u{1f}';
|
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
|
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||||
/// every field so no value can break it.
|
/// every field so no value can break it.
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
@@ -42,6 +42,8 @@ struct Host {
|
|||||||
port: u16,
|
port: u16,
|
||||||
fp: String,
|
fp: String,
|
||||||
pair: 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 {
|
impl Host {
|
||||||
@@ -54,13 +56,14 @@ impl Host {
|
|||||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||||
}
|
}
|
||||||
format!(
|
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.key),
|
||||||
clean(&self.name),
|
clean(&self.name),
|
||||||
clean(&self.addr),
|
clean(&self.addr),
|
||||||
self.port,
|
self.port,
|
||||||
clean(&self.fp),
|
clean(&self.fp),
|
||||||
clean(&self.pair),
|
clean(&self.pair),
|
||||||
|
clean(&self.mac),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +185,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
|||||||
port: info.get_port(),
|
port: info.get_port(),
|
||||||
fp: val("fp"),
|
fp: val("fp"),
|
||||||
pair: val("pair"),
|
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,
|
/// `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).
|
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||||
@@ -263,16 +267,18 @@ mod tests {
|
|||||||
port: 9777,
|
port: 9777,
|
||||||
fp: "ab".repeat(32),
|
fp: "ab".repeat(32),
|
||||||
pair: "required".into(),
|
pair: "required".into(),
|
||||||
|
mac: "aa:bb:cc:dd:ee:ff".into(),
|
||||||
};
|
};
|
||||||
let encoded = h.encode();
|
let encoded = h.encode();
|
||||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
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[0], "host-123");
|
||||||
assert_eq!(fields[1], "home-worker-2");
|
assert_eq!(fields[1], "home-worker-2");
|
||||||
assert_eq!(fields[2], "192.168.1.70");
|
assert_eq!(fields[2], "192.168.1.70");
|
||||||
assert_eq!(fields[3], "9777");
|
assert_eq!(fields[3], "9777");
|
||||||
assert_eq!(fields[4], "ab".repeat(32));
|
assert_eq!(fields[4], "ab".repeat(32));
|
||||||
assert_eq!(fields[5], "required");
|
assert_eq!(fields[5], "required");
|
||||||
|
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
|
||||||
assert!(
|
assert!(
|
||||||
!encoded.contains('\n'),
|
!encoded.contains('\n'),
|
||||||
"a record must never contain the record separator"
|
"a record must never contain the record separator"
|
||||||
@@ -282,7 +288,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||||
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
// 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 {
|
let h = Host {
|
||||||
key: "k\u{1f}injected".into(),
|
key: "k\u{1f}injected".into(),
|
||||||
name: "evil\nhost\r".into(),
|
name: "evil\nhost\r".into(),
|
||||||
@@ -290,9 +296,10 @@ mod tests {
|
|||||||
port: 9777,
|
port: 9777,
|
||||||
fp: "ab\u{1f}cd".into(),
|
fp: "ab\u{1f}cd".into(),
|
||||||
pair: "required\n".into(),
|
pair: "required\n".into(),
|
||||||
|
mac: "aa:bb\u{1f}cc".into(),
|
||||||
};
|
};
|
||||||
let encoded = h.encode();
|
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'));
|
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||||
assert_eq!(fields[0], "kinjected");
|
assert_eq!(fields[0], "kinjected");
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ mod feedback;
|
|||||||
mod mic;
|
mod mic;
|
||||||
mod session;
|
mod session;
|
||||||
mod stats;
|
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
|
/// 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.
|
/// `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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,5 +11,22 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
||||||
|
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
||||||
|
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
||||||
|
be requested from and approved by Apple for the App ID, then enabled in the provisioning
|
||||||
|
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
||||||
|
|
||||||
|
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
||||||
|
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
|
||||||
|
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
|
||||||
|
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
|
||||||
|
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
|
||||||
|
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
|
||||||
|
file, no multicast entitlement needed). -->
|
||||||
|
<!--
|
||||||
|
<key>com.apple.developer.networking.multicast</key>
|
||||||
|
<true/>
|
||||||
|
-->
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ struct ContentView: View {
|
|||||||
_ host: StoredHost, launchID: String? = nil,
|
_ host: StoredHost, launchID: String? = nil,
|
||||||
allowTofu: Bool, requestAccess: Bool = false
|
allowTofu: Bool, requestAccess: Bool = false
|
||||||
) {
|
) {
|
||||||
|
prepareWake(for: host)
|
||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
@@ -426,6 +427,25 @@ struct ContentView: View {
|
|||||||
requestAccess: requestAccess)
|
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
|
/// 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
|
/// 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
|
/// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
|
||||||
@@ -455,7 +475,9 @@ struct ContentView: View {
|
|||||||
/// inside `connect`.)
|
/// inside `connect`.)
|
||||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||||
guard !model.isBusy else { return }
|
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)
|
store.add(host)
|
||||||
if d.allowsTofu {
|
if d.allowsTofu {
|
||||||
connect(host, allowTofu: true)
|
connect(host, allowTofu: true)
|
||||||
|
|||||||
@@ -154,7 +154,14 @@ struct HomeView: View {
|
|||||||
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
||||||
onForget: { store.forgetIdentity(host) },
|
onForget: { store.forgetIdentity(host) },
|
||||||
onRemove: { store.remove(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 {
|
private var discoveredSection: some View {
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ struct HostCardView: View {
|
|||||||
let onRemove: () -> Void
|
let onRemove: () -> Void
|
||||||
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
||||||
var onBrowseLibrary: (() -> Void)? = nil
|
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 {
|
var body: some View {
|
||||||
let m = CardMetrics.current
|
let m = CardMetrics.current
|
||||||
@@ -138,6 +141,9 @@ struct HostCardView: View {
|
|||||||
if let onBrowseLibrary {
|
if let onBrowseLibrary {
|
||||||
Button("Browse Library…", action: 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 {
|
if host.pinnedSHA256 != nil {
|
||||||
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
// 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.
|
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
||||||
|
|||||||
@@ -26,9 +26,16 @@ struct StoredHost: Identifiable, Codable, Hashable {
|
|||||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
/// 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.)
|
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||||
var mgmtPort: UInt16?
|
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 displayName: String { name.isEmpty ? address : name }
|
||||||
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
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 {
|
extension StoredHost {
|
||||||
@@ -101,6 +108,16 @@ final class HostStore: ObservableObject {
|
|||||||
hosts[i].pinnedSHA256 = fingerprint
|
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
|
/// 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
|
/// 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).
|
/// `pair=optional` (the only case the connect path still offers the trust prompt).
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
|||||||
/// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional:
|
/// 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).
|
/// pairing is mandatory unless this is true (the policy authority is the host's advert).
|
||||||
public let allowsTofu: Bool
|
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
|
@MainActor
|
||||||
@@ -111,10 +117,15 @@ public final class HostDiscovery: ObservableObject {
|
|||||||
var fp: String?
|
var fp: String?
|
||||||
var pair: String?
|
var pair: String?
|
||||||
var id: String?
|
var id: String?
|
||||||
|
var macs: [String] = []
|
||||||
if case let .bonjour(txt) = result.metadata {
|
if case let .bonjour(txt) = result.metadata {
|
||||||
fp = Self.entry(txt, "fp")
|
fp = Self.entry(txt, "fp")
|
||||||
pair = Self.entry(txt, "pair")
|
pair = Self.entry(txt, "pair")
|
||||||
id = Self.entry(txt, "id")
|
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)
|
let conn = NWConnection(to: result.endpoint, using: .udp)
|
||||||
connections[key] = conn
|
connections[key] = conn
|
||||||
@@ -129,7 +140,7 @@ public final class HostDiscovery: ObservableObject {
|
|||||||
id: (id?.isEmpty == false) ? id! : name,
|
id: (id?.isEmpty == false) ? id! : name,
|
||||||
name: name, host: address, port: port.rawValue,
|
name: name, host: address, port: port.rawValue,
|
||||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||||
allowsTofu: pair == "optional")
|
allowsTofu: pair == "optional", macAddresses: macs)
|
||||||
self.publish()
|
self.publish()
|
||||||
}
|
}
|
||||||
conn.cancel()
|
conn.cancel()
|
||||||
|
|||||||
@@ -67,6 +67,53 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
|
|||||||
return s.withCString { body($0) }
|
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 {
|
public final class PunktfunkConnection {
|
||||||
private var handle: OpaquePointer?
|
private var handle: OpaquePointer?
|
||||||
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
||||||
|
|||||||
@@ -489,6 +489,40 @@ class Plugin:
|
|||||||
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
||||||
return {"ok": False, "error": reason}
|
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:
|
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
|
"""Fetch a paired host's game library via the flatpak client's headless
|
||||||
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
|
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>
|
|||||||
"set_settings",
|
"set_settings",
|
||||||
);
|
);
|
||||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
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");
|
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||||
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
||||||
export const updateClient = callable<
|
export const updateClient = callable<
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// and start it with RunGame. The wrapper then execs
|
// and start it with RunGame. The wrapper then execs
|
||||||
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
// `flatpak run io.unom.Punktfunk --connect <host>` 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
|
// 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
|
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||||
@@ -219,6 +219,11 @@ export async function launchStream(
|
|||||||
port: number,
|
port: number,
|
||||||
opts: LaunchOpts = {},
|
opts: LaunchOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// 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();
|
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
|
// 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).
|
// 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
|
// 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.
|
// script rides behind it as an argument and reads PF_* from the environment.
|
||||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
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);
|
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ pub fn run() -> glib::ExitCode {
|
|||||||
if let Some(target) = crate::cli::arg_value("--library") {
|
if let Some(target) = crate::cli::arg_value("--library") {
|
||||||
return crate::cli::headless_library(&target);
|
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);
|
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
|
// 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.
|
// launch its own primary instance instead of forwarding to a still-registered name.
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
|
|||||||
eprintln!("--connect: unparsable port in '{target}', using default 9777");
|
eprintln!("--connect: unparsable port in '{target}', using default 9777");
|
||||||
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 {
|
Some(ConnectRequest {
|
||||||
name: addr.clone(),
|
name: addr.clone(),
|
||||||
addr,
|
addr,
|
||||||
@@ -108,9 +116,39 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
|
|||||||
fp_hex: None,
|
fp_hex: None,
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
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
|
/// `--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
|
/// 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
|
/// 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()),
|
fp_hex: k.map(|k| k.fp_hex.clone()),
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: k.map(|k| k.mac.clone()).unwrap_or_default(),
|
||||||
},
|
},
|
||||||
k.is_some_and(|k| k.paired),
|
k.is_some_and(|k| k.paired),
|
||||||
mgmt,
|
mgmt,
|
||||||
@@ -210,6 +249,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
|||||||
),
|
),
|
||||||
pair_optional: true,
|
pair_optional: true,
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: Vec::new(),
|
||||||
};
|
};
|
||||||
let mock_advert =
|
let mock_advert =
|
||||||
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
||||||
@@ -221,6 +261,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
|||||||
fp_hex: fp.to_string(),
|
fp_hex: fp.to_string(),
|
||||||
pair: "required".to_string(),
|
pair: "required".to_string(),
|
||||||
mgmt_port: None,
|
mgmt_port: None,
|
||||||
|
mac: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// What the self-capture renders: the main window, except for scenes that open their
|
// What the self-capture renders: the main window, except for scenes that open their
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ pub struct DiscoveredHost {
|
|||||||
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
|
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
|
||||||
/// library client then falls back to the well-known default.
|
/// library client then falls back to the well-known default.
|
||||||
pub mgmt_port: Option<u16>,
|
pub mgmt_port: Option<u16>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One discovery update for the UI's advert map.
|
/// One discovery update for the UI's advert map.
|
||||||
@@ -81,6 +84,11 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
|||||||
fp_hex: val("fp"),
|
fp_hex: val("fp"),
|
||||||
pair: val("pair"),
|
pair: val("pair"),
|
||||||
mgmt_port: val("mgmt").parse().ok(),
|
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) => {
|
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ mod ui_trust;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
|
mod wol;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn main() -> gtk::glib::ExitCode {
|
fn main() -> gtk::glib::ExitCode {
|
||||||
app::run()
|
app::run()
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ pub struct KnownHost {
|
|||||||
/// most-recent card with the accent bar. `default` so pre-existing stores load.
|
/// most-recent card with the accent bar. `default` so pre-existing stores load.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_used: Option<u64>,
|
pub last_used: Option<u64>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
@@ -115,6 +120,10 @@ impl KnownHosts {
|
|||||||
if entry.last_used.is_some() {
|
if entry.last_used.is_some() {
|
||||||
h.last_used = entry.last_used;
|
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 {
|
} else {
|
||||||
self.hosts.push(entry);
|
self.hosts.push(entry);
|
||||||
}
|
}
|
||||||
@@ -132,10 +141,31 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
|||||||
fp_hex: fp_hex.to_string(),
|
fp_hex: fp_hex.to_string(),
|
||||||
paired,
|
paired,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
|
mac: Vec::new(),
|
||||||
});
|
});
|
||||||
let _ = known.save();
|
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
|
/// 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.
|
/// most-recent accent). No-op when the fingerprint isn't stored.
|
||||||
pub fn touch_last_used(fp_hex: &str) {
|
pub fn touch_last_used(fp_hex: &str) {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ pub struct ConnectRequest {
|
|||||||
/// `("steam:570", "Dota 2")`) — set by the library page's card activation; the id
|
/// `("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.
|
/// rides the Hello and the name titles the stream page. `None` = plain desktop session.
|
||||||
pub launch: Option<(String, String)>,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectRequest {
|
impl ConnectRequest {
|
||||||
@@ -314,6 +317,11 @@ fn rebuild(state: &Rc<State>) {
|
|||||||
state.saved_flow.remove_all();
|
state.saved_flow.remove_all();
|
||||||
for k in &known.hosts {
|
for k in &known.hosts {
|
||||||
let online = adverts.values().any(|a| matches(k, a));
|
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());
|
let recent = most_recent.as_deref() == Some(k.fp_hex.as_str());
|
||||||
state
|
state
|
||||||
.saved_flow
|
.saved_flow
|
||||||
@@ -421,6 +429,7 @@ fn saved_card(
|
|||||||
// connect; TOFU eligibility is irrelevant.
|
// connect; TOFU eligibility is irrelevant.
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: k.mac.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Presence pip + spelled-out state, then the trust pill.
|
// Presence pip + spelled-out state, then the trust pill.
|
||||||
@@ -492,11 +501,21 @@ fn saved_card(
|
|||||||
Box::new(move || forget_dialog(&state, &fp, &name)),
|
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));
|
overlay.insert_action_group("card", Some(&actions));
|
||||||
|
|
||||||
let menu = gio::Menu::new();
|
let menu = gio::Menu::new();
|
||||||
menu.append(Some("Pair with PIN…"), Some("card.pair"));
|
menu.append(Some("Pair with PIN…"), Some("card.pair"));
|
||||||
menu.append(Some("Test network speed…"), Some("card.speed"));
|
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
|
// 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
|
// item is offered on every saved card — an unpaired host answers with the friendly
|
||||||
// "not paired" error state rather than the entry hiding itself.
|
// "not paired" error state rather than the entry hiding itself.
|
||||||
@@ -521,7 +540,16 @@ fn saved_card(
|
|||||||
overlay.add_controller(right_click);
|
overlay.add_controller(right_click);
|
||||||
|
|
||||||
let on_connect = state.cbs.on_connect.clone();
|
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
|
child
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +567,7 @@ fn discovered_card(
|
|||||||
// required/empty means mandatory PIN.
|
// required/empty means mandatory PIN.
|
||||||
pair_optional: a.pair == "optional",
|
pair_optional: a.pair == "optional",
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: a.mac.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
@@ -674,6 +703,7 @@ fn add_host_dialog(state: &Rc<State>) {
|
|||||||
// Manual entry carries no advertised policy — never eligible for TOFU.
|
// Manual entry carries no advertised policy — never eligible for TOFU.
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: None,
|
launch: None,
|
||||||
|
mac: Vec::new(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Ipv4Addr>) {
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -245,6 +245,7 @@ fn connect_with(
|
|||||||
port: target.port,
|
port: target.port,
|
||||||
fp_hex: trust::hex(&fingerprint),
|
fp_hex: trust::hex(&fingerprint),
|
||||||
paired: persist_paired,
|
paired: persist_paired,
|
||||||
|
mac: target.mac.clone(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use windows_reactor::*;
|
|||||||
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
||||||
const MENU_CONNECT: &str = "Connect";
|
const MENU_CONNECT: &str = "Connect";
|
||||||
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
||||||
|
const MENU_WAKE: &str = "Wake host";
|
||||||
const MENU_RENAME: &str = "Rename\u{2026}";
|
const MENU_RENAME: &str = "Rename\u{2026}";
|
||||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||||
|
|
||||||
@@ -318,10 +319,19 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
port: k.port,
|
port: k.port,
|
||||||
fp_hex: Some(k.fp_hex.clone()),
|
fp_hex: Some(k.fp_hex.clone()),
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
|
mac: k.mac.clone(),
|
||||||
};
|
};
|
||||||
let online = hosts
|
let online = hosts
|
||||||
.iter()
|
.iter()
|
||||||
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port));
|
.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 menu = {
|
||||||
let (svc, target) = (props.svc.clone(), target.clone());
|
let (svc, target) = (props.svc.clone(), target.clone());
|
||||||
let (sf, sr) = (set_forget.clone(), set_rename.clone());
|
let (sf, sr) = (set_forget.clone(), set_rename.clone());
|
||||||
@@ -331,17 +341,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
.subtle()
|
.subtle()
|
||||||
.tooltip("More options")
|
.tooltip("More options")
|
||||||
.automation_name("More options")
|
.automation_name("More options")
|
||||||
.menu_flyout(vec![
|
.menu_flyout({
|
||||||
menu_item(MENU_CONNECT),
|
let mut items = vec![menu_item(MENU_CONNECT), menu_item(MENU_SPEED)];
|
||||||
menu_item(MENU_SPEED),
|
// Offer an explicit wake only when the host is offline and we have a MAC.
|
||||||
menu_item(MENU_RENAME),
|
if can_wake {
|
||||||
menu_separator(),
|
items.push(menu_item(MENU_WAKE));
|
||||||
menu_item(MENU_FORGET),
|
}
|
||||||
])
|
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() {
|
.on_item_clicked(move |item: String| match item.as_str() {
|
||||||
MENU_CONNECT => {
|
MENU_CONNECT => {
|
||||||
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
||||||
}
|
}
|
||||||
|
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
||||||
MENU_SPEED => {
|
MENU_SPEED => {
|
||||||
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
||||||
// New run: invalidate any still-in-flight probe, reset the screen.
|
// New run: invalidate any still-in-flight probe, reset the screen.
|
||||||
@@ -369,7 +384,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
if k.paired { Pill::Good } else { Pill::Info },
|
if k.paired { Pill::Good } else { Pill::Info },
|
||||||
),
|
),
|
||||||
Some(menu),
|
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));
|
body.push(tile_grid(tiles, cols));
|
||||||
@@ -406,6 +428,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
port: h.port,
|
port: h.port,
|
||||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||||
pair_optional: h.pair == "optional",
|
pair_optional: h.pair == "optional",
|
||||||
|
mac: h.mac.clone(),
|
||||||
};
|
};
|
||||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||||
let (badge, kind) = if h.pair == "required" {
|
let (badge, kind) = if h.pair == "required" {
|
||||||
@@ -486,6 +509,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
port,
|
port,
|
||||||
fp_hex: None,
|
fp_hex: None,
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
|
mac: Vec::new(),
|
||||||
},
|
},
|
||||||
&ss,
|
&ss,
|
||||||
&st,
|
&st,
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ pub(crate) struct Target {
|
|||||||
pub(crate) port: u16,
|
pub(crate) port: u16,
|
||||||
pub(crate) fp_hex: Option<String>,
|
pub(crate) fp_hex: Option<String>,
|
||||||
pub(crate) pair_optional: bool,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable app services handed to the page components as props. Each routed screen that uses
|
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
|||||||
port: target3.port,
|
port: target3.port,
|
||||||
fp_hex: trust::hex(&fp),
|
fp_hex: trust::hex(&fp),
|
||||||
paired: true,
|
paired: true,
|
||||||
|
mac: target3.mac.clone(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ pub struct DiscoveredHost {
|
|||||||
pub fp_hex: String,
|
pub fp_hex: String,
|
||||||
/// Pairing requirement: `"required"` or `"optional"`.
|
/// Pairing requirement: `"required"` or `"optional"`.
|
||||||
pub pair: String,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||||
@@ -63,6 +66,11 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
|||||||
port: info.get_port(),
|
port: info.get_port(),
|
||||||
fp_hex: val("fp"),
|
fp_hex: val("fp"),
|
||||||
pair: val("pair"),
|
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() {
|
if tx.send_blocking(host).is_err() {
|
||||||
break; // UI gone — stop browsing
|
break; // UI gone — stop browsing
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ mod trust;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
|
mod wol;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn main() {
|
fn main() {
|
||||||
// With #![windows_subsystem = "windows"] the process starts with no console, so the GUI/MSIX
|
// 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,
|
port,
|
||||||
fp_hex: trust::hex(&fp),
|
fp_hex: trust::hex(&fp),
|
||||||
paired: true,
|
paired: true,
|
||||||
|
mac: Vec::new(),
|
||||||
});
|
});
|
||||||
let _ = k.save();
|
let _ = k.save();
|
||||||
tracing::info!(fp = %trust::hex(&fp), "paired");
|
tracing::info!(fp = %trust::hex(&fp), "paired");
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ pub struct KnownHost {
|
|||||||
pub fp_hex: String,
|
pub fp_hex: String,
|
||||||
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
||||||
pub paired: bool,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
@@ -106,12 +111,36 @@ impl KnownHosts {
|
|||||||
h.addr = entry.addr;
|
h.addr = entry.addr;
|
||||||
h.port = entry.port;
|
h.port = entry.port;
|
||||||
h.paired |= entry.paired;
|
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 {
|
} else {
|
||||||
self.hosts.push(entry);
|
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
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -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<Ipv4Addr>) {
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user