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
+5
View File
@@ -142,6 +142,11 @@ pub fn run() -> glib::ExitCode {
if let Some(target) = crate::cli::arg_value("--library") {
return crate::cli::headless_library(&target);
}
// Headless Wake-on-LAN (no GTK window): `--wake host[:port]`. The Decky wrapper calls this
// before the stream launch so a sleeping host is up by the time `--connect` runs.
if crate::cli::arg_value("--wake").is_some() {
return crate::cli::cli_wake();
}
let mut builder = adw::Application::builder().application_id(APP_ID);
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each
// launch its own primary instance instead of forwarding to a still-registered name.
+41
View File
@@ -101,6 +101,14 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
eprintln!("--connect: unparsable port in '{target}', using default 9777");
9777
});
// Pull the wake MAC(s) from the store (learned from the host's mDNS `mac` TXT while it was
// online) so a `--connect` to a known host can still be woken if we add that later.
let mac = crate::trust::KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port)
.map(|h| h.mac.clone())
.unwrap_or_default();
Some(ConnectRequest {
name: addr.clone(),
addr,
@@ -108,9 +116,39 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
fp_hex: None,
pair_optional: false,
launch: arg_value("--launch").map(|id| (id.clone(), id)),
mac,
})
}
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, without
/// opening a window. The Decky wrapper calls this before launching the stream so a sleeping host
/// is up by the time `--connect` runs. The MAC comes from the known-hosts store (learned from the
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
pub fn cli_wake() -> glib::ExitCode {
let Some(target) = arg_value("--wake") else {
eprintln!("--wake requires host[:port]");
return glib::ExitCode::FAILURE;
};
let (addr, port) = parse_host_port(&target);
let port = port.unwrap_or(9777);
let mac = crate::trust::KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port)
.map(|h| h.mac.clone())
.unwrap_or_default();
if mac.is_empty() {
eprintln!(
"--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \
advertised MAC is learned"
);
return glib::ExitCode::FAILURE;
}
crate::wol::wake(&mac, addr.parse().ok());
println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len());
glib::ExitCode::SUCCESS
}
/// `--browse host[:port]` — open the gamepad library launcher for that host instead of
/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must
/// already be paired: the stored pin is what lets the launcher fetch the library and
@@ -138,6 +176,7 @@ pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> {
fp_hex: k.map(|k| k.fp_hex.clone()),
pair_optional: false,
launch: None,
mac: k.map(|k| k.mac.clone()).unwrap_or_default(),
},
k.is_some_and(|k| k.paired),
mgmt,
@@ -210,6 +249,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
),
pair_optional: true,
launch: None,
mac: Vec::new(),
};
let mock_advert =
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
@@ -221,6 +261,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
fp_hex: fp.to_string(),
pair: "required".to_string(),
mgmt_port: None,
mac: Vec::new(),
};
// What the self-capture renders: the main window, except for scenes that open their
+8
View File
@@ -22,6 +22,9 @@ pub struct DiscoveredHost {
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
/// library client then falls back to the well-known default.
pub mgmt_port: Option<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.
@@ -81,6 +84,11 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
fp_hex: val("fp"),
pair: val("pair"),
mgmt_port: val("mgmt").parse().ok(),
mac: val("mac")
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
})
}
ServiceEvent::ServiceRemoved(_ty, fullname) => {
+2
View File
@@ -40,6 +40,8 @@ mod ui_trust;
#[cfg(target_os = "linux")]
mod video;
mod wol;
#[cfg(target_os = "linux")]
fn main() -> gtk::glib::ExitCode {
app::run()
+30
View File
@@ -60,6 +60,11 @@ pub struct KnownHost {
/// most-recent card with the accent bar. `default` so pre-existing stores load.
#[serde(default)]
pub last_used: Option<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)]
@@ -115,6 +120,10 @@ impl KnownHosts {
if entry.last_used.is_some() {
h.last_used = entry.last_used;
}
// Likewise a trust-decision upsert (which carries no MAC) must not wipe learned MACs.
if !entry.mac.is_empty() {
h.mac = entry.mac;
}
} else {
self.hosts.push(entry);
}
@@ -132,10 +141,31 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
fp_hex: fp_hex.to_string(),
paired,
last_used: None,
mac: Vec::new(),
});
let _ = known.save();
}
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
/// the hosts page can call it on every discovery tick without churning the store.
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
if mac.is_empty() {
return;
}
let mut known = KnownHosts::load();
let Some(h) = known.hosts.iter_mut().find(|h| {
(!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port)
}) else {
return;
};
if h.mac == mac {
return;
}
h.mac = mac.to_vec();
let _ = known.save();
}
/// Stamp "now" as this host's last successful connect (drives the hosts page's
/// most-recent accent). No-op when the fingerprint isn't stored.
pub fn touch_last_used(fp_hex: &str) {
+31 -1
View File
@@ -29,6 +29,9 @@ pub struct ConnectRequest {
/// `("steam:570", "Dota 2")`) — set by the library page's card activation; the id
/// rides the Hello and the name titles the stream page. `None` = plain desktop session.
pub launch: Option<(String, String)>,
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert). Used to send a
/// magic packet before connecting to an offline host. Empty when none is known.
pub mac: Vec<String>,
}
impl ConnectRequest {
@@ -314,6 +317,11 @@ fn rebuild(state: &Rc<State>) {
state.saved_flow.remove_all();
for k in &known.hosts {
let online = adverts.values().any(|a| matches(k, a));
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake it
// once it sleeps and stops advertising (no-op / no disk write when unchanged).
if let Some(a) = adverts.values().find(|a| matches(k, a) && !a.mac.is_empty()) {
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
}
let recent = most_recent.as_deref() == Some(k.fp_hex.as_str());
state
.saved_flow
@@ -421,6 +429,7 @@ fn saved_card(
// connect; TOFU eligibility is irrelevant.
pair_optional: false,
launch: None,
mac: k.mac.clone(),
};
// Presence pip + spelled-out state, then the trust pill.
@@ -492,11 +501,21 @@ fn saved_card(
Box::new(move || forget_dialog(&state, &fp, &name)),
);
}
{
// Explicit "just wake it" (the tap-to-connect already auto-wakes an offline host).
let mac = k.mac.clone();
let addr = k.addr.clone();
add("wake", Box::new(move || crate::wol::wake(&mac, addr.parse().ok())));
}
overlay.insert_action_group("card", Some(&actions));
let menu = gio::Menu::new();
menu.append(Some("Pair with PIN…"), Some("card.pair"));
menu.append(Some("Test network speed…"), Some("card.speed"));
// Offer an explicit wake only when the host is offline and we actually have a MAC to target.
if !online && !k.mac.is_empty() {
menu.append(Some("Wake host"), Some("card.wake"));
}
// Experimental (Preferences gate, Apple parity): browse the host's game library. The
// item is offered on every saved card — an unpaired host answers with the friendly
// "not paired" error state rather than the entry hiding itself.
@@ -521,7 +540,16 @@ fn saved_card(
overlay.add_controller(right_click);
let on_connect = state.cbs.on_connect.clone();
child.connect_activate(move |_| on_connect(req.clone()));
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, fire a
// magic packet before connecting — the connect's own retry/timeout gives a woken host time to
// come up. A host that's genuinely off/unreachable then fails the connect as before.
let wake_first = !online && !req.mac.is_empty();
child.connect_activate(move |_| {
if wake_first {
crate::wol::wake(&req.mac, req.addr.parse().ok());
}
on_connect(req.clone());
});
child
}
@@ -539,6 +567,7 @@ fn discovered_card(
// required/empty means mandatory PIN.
pair_optional: a.pair == "optional",
launch: None,
mac: a.mac.clone(),
};
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
@@ -674,6 +703,7 @@ fn add_host_dialog(state: &Rc<State>) {
// Manual entry carries no advertised policy — never eligible for TOFU.
pair_optional: false,
launch: None,
mac: Vec::new(),
});
});
}
+24
View File
@@ -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"),
}
}