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>
104 lines
4.3 KiB
Python
104 lines
4.3 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
import sys
|
|
import urllib.parse
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "py_modules"))
|
|
|
|
import decky # noqa: E402
|
|
|
|
from mudfish_decky import installer, system # noqa: E402
|
|
from mudfish_decky.client import MudfishClient # noqa: E402
|
|
from mudfish_decky.controller import Controller # noqa: E402
|
|
from mudfish_decky.relay import LoginRelay # noqa: E402
|
|
from mudfish_decky.store import Store # noqa: E402
|
|
|
|
RESUME_POLL_SECONDS = 2
|
|
LATEST_POLL_TICKS = 1800 # every hour at 2s per tick
|
|
LOGIN_RELAY_SECONDS = 300
|
|
|
|
|
|
class Plugin:
|
|
controller = None
|
|
|
|
def _get_controller(self) -> Controller:
|
|
if self.controller is None:
|
|
installed = system.find_mudfish()
|
|
base = system.api_base(installed[1]) if installed else system.DEFAULT_API
|
|
store = Store(os.path.join(decky.DECKY_PLUGIN_SETTINGS_DIR, "settings.json"))
|
|
target = urllib.parse.urlsplit(base)
|
|
self.controller = Controller(
|
|
MudfishClient(base), system, store,
|
|
relay_factory=lambda: LoginRelay(target=(target.hostname, target.port or 80), ttl=LOGIN_RELAY_SECONDS),
|
|
installer=installer,
|
|
)
|
|
return self.controller
|
|
|
|
async def start_login(self) -> dict:
|
|
return await asyncio.to_thread(self._get_controller().start_login)
|
|
|
|
async def stop_login(self) -> dict:
|
|
return await asyncio.to_thread(self._get_controller().stop_login)
|
|
|
|
async def install_mudfish(self) -> dict:
|
|
return await asyncio.to_thread(self._get_controller().install)
|
|
|
|
async def update_mudfish(self) -> dict:
|
|
return await asyncio.to_thread(self._get_controller().update)
|
|
|
|
async def uninstall_mudfish(self) -> dict:
|
|
return await asyncio.to_thread(self._get_controller().uninstall)
|
|
|
|
async def get_state(self) -> dict:
|
|
return await asyncio.to_thread(self._get_controller().state)
|
|
|
|
async def connect(self) -> dict:
|
|
return await asyncio.to_thread(self._get_controller().connect)
|
|
|
|
async def disconnect(self) -> dict:
|
|
return await asyncio.to_thread(self._get_controller().disconnect)
|
|
|
|
async def set_node(self, choice: str) -> dict:
|
|
return await asyncio.to_thread(self._get_controller().set_node, str(choice))
|
|
|
|
async def list_nodes(self) -> list:
|
|
return await asyncio.to_thread(self._get_controller().nodes)
|
|
|
|
async def _main(self):
|
|
log = logging.getLogger("mudfish_decky")
|
|
log.setLevel(logging.INFO)
|
|
for handler in decky.logger.handlers:
|
|
log.addHandler(handler)
|
|
controller = self._get_controller()
|
|
# Bring up the idle launcher and sign in so nodes are listed. Never starts the tunnel.
|
|
await asyncio.to_thread(controller.prepare)
|
|
# Clean up Mudfish if the plugin folder disappears (uninstall), even if Decky kills us first.
|
|
plugin_dir = os.path.dirname(os.path.abspath(__file__))
|
|
await asyncio.to_thread(system.ensure_cleanup_watcher, plugin_dir, decky.DECKY_PLUGIN_SETTINGS_DIR)
|
|
decky.logger.info("Mudfish VPN plugin loaded")
|
|
# Watch for sleep/resume even while the panel is closed; a resumed tunnel is turned off.
|
|
# Also look for a newer Mudfish release now and then (the controller limits it to once a day).
|
|
ticks = 0
|
|
while True:
|
|
try:
|
|
controller.check_resume(system.sleep_offset())
|
|
if ticks % LATEST_POLL_TICKS == 0:
|
|
await asyncio.to_thread(controller.refresh_latest)
|
|
except Exception as e:
|
|
decky.logger.warning("background check failed: %s", e)
|
|
ticks += 1
|
|
await asyncio.sleep(RESUME_POLL_SECONDS)
|
|
|
|
# _unload/_uninstall run synchronously on purpose: Decky closes the plugin socket right after
|
|
# asking it to stop, and its listener then spins on EOF and starves the event loop, so an
|
|
# awaited worker thread would never resume and the plugin is SIGKILLed after 5 seconds.
|
|
async def _unload(self):
|
|
self._get_controller().shutdown()
|
|
decky.logger.info("Mudfish VPN plugin unloaded")
|
|
|
|
async def _uninstall(self):
|
|
# Removing the plugin removes Mudfish too (user decision): VPN off, daemon stopped, /opt/mudfish gone.
|
|
self._get_controller().uninstall()
|
|
decky.logger.info("Mudfish removed with the plugin")
|