Files
ysj7705andClaude Opus 5.5 bc45f66da5 fix: reliable Mudfish removal on plugin uninstall
Decky 3.3 often SIGKILLs plugins 5s after SIGTERM because its socket
listener spins on EOF and starves the event loop (seen for several
plugins). So:
- _unload/_uninstall run synchronously (an awaited worker thread could
  never resume), and the daemon stop no longer waits.
- A transient systemd path unit watches the plugins folder; if this
  plugin's folder stays gone for 20s it stops the daemon and deletes
  /opt/mudfish and the plugin settings, then removes itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:14:51 +09:00

133 lines
4.8 KiB
Python

"""Steam Deck side effects: locating Mudfish, running its daemon, reading routes and the public IP."""
import json
import os
import subprocess
import time
import urllib.request
from typing import Callable, Optional, Tuple
from .client import ssl_context
from .routes import delete_routes # noqa: F401 (re-exported for the controller)
MUDFISH_ROOT = "/opt/mudfish"
DAEMON_UNIT = "mudfish-decky"
DEFAULT_API = "http://127.0.0.1:8282"
def _version_key(name: str):
try:
return tuple(int(p) for p in name.split("."))
except ValueError:
return None
def find_mudfish(root: str = MUDFISH_ROOT) -> Optional[Tuple[str, str]]:
"""(daemon binary, var dir) of the highest installed Mudfish version."""
if not os.path.isdir(root):
return None
versions = []
for name in os.listdir(root):
key = _version_key(name)
binary = os.path.join(root, name, "bin", "mudrun-headless")
if key is not None and os.path.isfile(binary):
versions.append((key, name))
if not versions:
return None
name = max(versions)[1]
return os.path.join(root, name, "bin", "mudrun-headless"), os.path.join(root, name, "var")
def version_of(bin_path: str) -> str:
"""'/opt/mudfish/6.6.0/bin/mudrun-headless' -> '6.6.0'."""
return os.path.basename(os.path.dirname(os.path.dirname(bin_path)))
def stop_daemon(run=subprocess.run, wait: bool = True) -> None:
cmd = ["systemctl", "stop", DAEMON_UNIT] + ([] if wait else ["--no-block"])
run(cmd, capture_output=True, timeout=20)
def remove_nm_connection(run=subprocess.run) -> None:
"""Mudfish registers a NetworkManager connection for tap0; it is normally removed on stop."""
run(["nmcli", "connection", "delete", "mudfish-tap"], capture_output=True, timeout=10)
CLEANUP_UNIT = "mudfish-decky-cleanup"
CLEANUP_DELAY = 20
def ensure_cleanup_watcher(plugin_dir: str, settings_dir: str, run=subprocess.run) -> None:
"""Remove Mudfish when the plugin is uninstalled, independently of the plugin process.
Decky 3.3 often SIGKILLs plugins before _uninstall runs, so a transient systemd path unit watches
the plugins directory; when this plugin's folder has stayed gone for CLEANUP_DELAY seconds (not a
reinstall/update), it stops the daemon and deletes /opt/mudfish and the plugin settings.
"""
if run(["systemctl", "is-active", "--quiet", f"{CLEANUP_UNIT}.path"],
capture_output=True, timeout=10).returncode == 0:
return
script = "; ".join([
f"sleep {CLEANUP_DELAY}",
f'[ -e "{plugin_dir}/plugin.json" ] && exit 0',
f"systemctl stop {DAEMON_UNIT}",
"nmcli connection delete mudfish-tap",
f"rm -rf {MUDFISH_ROOT}",
f'rm -rf "{settings_dir}"',
f"systemctl stop {CLEANUP_UNIT}.path",
])
run(["systemd-run", f"--unit={CLEANUP_UNIT}", f"--path-property=PathChanged={os.path.dirname(plugin_dir)}",
"/bin/sh", "-c", script], capture_output=True, timeout=10)
def api_base(var_dir: str) -> str:
try:
with open(os.path.join(var_dir, ".ipport")) as f:
ipport = f.read().strip()
except OSError:
return DEFAULT_API
return f"http://{ipport}" if ipport else DEFAULT_API
def ensure_daemon(bin_path: str, is_up: Callable[[], bool], run=subprocess.run, sleep=time.sleep) -> None:
"""Start the launcher as a transient systemd unit so it outlives desktop/game mode switches."""
if is_up():
return
run(["systemd-run", f"--unit={DAEMON_UNIT}", "--collect", bin_path, "-B"], capture_output=True, timeout=10)
for _ in range(20):
sleep(0.5)
if is_up():
return
def sleep_offset(clock=time.clock_gettime, boot=getattr(time, "CLOCK_BOOTTIME", None),
mono=time.CLOCK_MONOTONIC) -> float:
"""Total time spent suspended since boot: BOOTTIME keeps counting during suspend, MONOTONIC doesn't."""
return clock(boot) - clock(mono)
def tap_up() -> bool:
return os.path.exists("/sys/class/net/tap0")
def route_text(run=subprocess.run) -> str:
return run(["ip", "-4", "route"], capture_output=True, text=True, timeout=5).stdout
def lan_ip(run=subprocess.run) -> Optional[str]:
"""The Deck's source address for outbound traffic (its Wi-Fi/LAN IP when the VPN is off)."""
try:
out = run(["ip", "-4", "route", "get", "1.1.1.1"], capture_output=True, text=True, timeout=5).stdout
except Exception:
return None
parts = out.split()
return parts[parts.index("src") + 1] if "src" in parts[:-1] else None
def public_ip() -> Optional[dict]:
try:
with urllib.request.urlopen("https://ipinfo.io/json", timeout=8, context=ssl_context()) as resp:
data = json.load(resp)
return {"ip": data.get("ip"), "country": data.get("country")}
except Exception:
return None