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

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
+34
View File
@@ -489,6 +489,40 @@ class Plugin:
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
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:
"""Fetch a paired host's game library via the flatpak client's headless
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
+6
View File
@@ -122,6 +122,12 @@ export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>
"set_settings",
);
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");
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
export const updateClient = callable<
+7 -1
View File
@@ -8,7 +8,7 @@
// and start it with RunGame. The wrapper then execs
// `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
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
@@ -219,6 +219,11 @@ export async function launchStream(
port: number,
opts: LaunchOpts = {},
): 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();
// 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).
@@ -240,6 +245,7 @@ export async function launchStream(
// 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.
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);
}