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
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user