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")