diff --git a/.gitignore b/.gitignore index 396fb85..9161579 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ Thumbs.db # Ignore built ts files dist/ decky-ftpd-dist/ +decky-ftpd.zip __pycache__/ diff --git a/decky-ftpd.zip b/decky-ftpd.zip deleted file mode 100644 index 4407f31..0000000 Binary files a/decky-ftpd.zip and /dev/null differ diff --git a/main.py b/main.py index aa7d7fb..b6d50bb 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,8 @@ import warnings from typing import TYPE_CHECKING +from settings import SettingsManager # pyright: ignore[reportMissingImports] + import decky if TYPE_CHECKING: @@ -34,17 +36,31 @@ class Plugin: _server: "FTPServer | None" = None _server_thread = None _running = False + _settings: "SettingsManager | None" = None + _loop: "asyncio.AbstractEventLoop | None" = None - # TODO: Set in settings page - _port: int = 2121 - _root: str = decky.DECKY_USER_HOME + DEFAULTS = { + "port": 2121, + "root_dir": "/", + "passive_port_start": 50000, + "passive_port_end": 50100, + } - # ── lifecycle ────────────────────────────────────────────────────────── async def _main(self): - self.loop = asyncio.get_event_loop() + self._loop = asyncio.get_running_loop() _ensure_pyftpdlib() + + settings = SettingsManager( + name="settings", + settings_directory=decky.DECKY_PLUGIN_SETTINGS_DIR, + ) + settings.read() + self._settings = settings + decky.logger.info( - "decky-ftpd loaded (port=%d, root=%s)", self._port, self._root + "decky-ftpd loaded (port=%d, root=%s)", + self._get("port"), + self._get("root_dir"), ) async def _unload(self): @@ -55,9 +71,22 @@ async def _uninstall(self): decky.logger.info("decky-ftpd uninstalled") async def _migration(self): - decky.logger.info("decky-ftpd migration (nothing to migrate yet)") + decky.logger.info("decky-ftpd: nothing to migrate") + + async def _emit_status(self): + try: + await decky.emit( + "ftpd_status", + { + "running": self._running, + "ip": _get_local_ip() if self._running else "", + "port": self._get("port"), + "root": self._get("root_dir"), + }, + ) + except Exception as e: + decky.logger.warning("decky-ftpd: emit failed — %s", e) - # ── callable: start ──────────────────────────────────────────────────── async def start_server(self) -> dict: if self._running: return {"success": True, "already": True} @@ -71,35 +100,56 @@ async def start_server(self) -> dict: with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) authorizer = DummyAuthorizer() - authorizer.add_anonymous(self._root, perm="elradfmwMT") + authorizer.add_anonymous(self._get("root_dir"), perm="elradfmwMT") + + p_start = self._get("passive_port_start") + p_end = self._get("passive_port_end") class DeckFTPHandler(FTPHandler): - passive_ports = range(50000, 50100) + passive_ports = range(p_start, p_end) banner = "Steam Deck FTP ready." - DeckFTPHandler.authorizer = authorizer # ← this was missing! + DeckFTPHandler.authorizer = authorizer - self._server = FTPServer(("0.0.0.0", self._port), DeckFTPHandler) + self._server = FTPServer(("0.0.0.0", self._get("port")), DeckFTPHandler) self._server.max_cons = 10 self._server.max_cons_per_ip = 3 server = self._server def _serve(): - decky.logger.info("decky-ftpd: server started on port %d", self._port) - server.serve_forever() + decky.logger.info( + "decky-ftpd: server started on port %d", self._get("port") + ) + try: + server.serve_forever() + except Exception as exc: + decky.logger.error("decky-ftpd: server thread crashed — %s", exc) + finally: + if self._server is server: + self._running = False + loop = self._loop + if loop is not None: + try: + asyncio.run_coroutine_threadsafe( + self._emit_status(), loop + ) + except Exception: + pass self._server_thread = threading.Thread(target=_serve, daemon=True) self._server_thread.start() self._running = True + await self._emit_status() return {"success": True} except Exception as exc: + self._running = False + await self._emit_status() decky.logger.error("decky-ftpd: failed to start — %s", exc) return {"success": False, "error": str(exc)} - # ── callable: stop ───────────────────────────────────────────────────── async def stop_server(self) -> dict: if not self._running: return {"success": True, "already": True} @@ -109,17 +159,84 @@ async def stop_server(self) -> dict: self._server.close_all() self._server = None self._running = False + await self._emit_status() decky.logger.info("decky-ftpd: server stopped") return {"success": True} except Exception as exc: decky.logger.error("decky-ftpd: failed to stop — %s", exc) return {"success": False, "error": str(exc)} - # ── callable: status ─────────────────────────────────────────────────── async def get_status(self) -> dict: return { "running": self._running, "ip": _get_local_ip() if self._running else "", - "port": self._port, - "root": self._root, + "port": self._get("port"), + "root": self._get("root_dir"), } + + def _get(self, key: str): + assert self._settings is not None + return self._settings.getSetting(key, self.DEFAULTS[key]) + + async def get_settings(self) -> dict: + return {k: self._get(k) for k in self.DEFAULTS} + + async def save_settings(self, new_settings: dict) -> dict: + try: + assert self._settings is not None + + port = int(new_settings.get("port", self.DEFAULTS["port"])) + root = str(new_settings.get("root_dir", self.DEFAULTS["root_dir"])) + p_start = int( + new_settings.get( + "passive_port_start", self.DEFAULTS["passive_port_start"] + ) + ) + p_end = int( + new_settings.get("passive_port_end", self.DEFAULTS["passive_port_end"]) + ) + + if not (1024 <= port <= 65535): + return {"success": False, "error": "Port must be 1024–65535."} + if not root.startswith("/"): + return { + "success": False, + "error": "Root must be an absolute path.", + } + if not (1024 <= p_start <= 65535 and 1024 <= p_end <= 65535): + return { + "success": False, + "error": "Passive ports must be 1024–65535.", + } + if p_end <= p_start: + return { + "success": False, + "error": "Passive end must be greater than start.", + } + if p_start <= port <= p_end: + return { + "success": False, + "error": "Control port must not sit inside the passive range.", + } + + self._settings.setSetting("port", port) + self._settings.setSetting("root_dir", root) + self._settings.setSetting("passive_port_start", p_start) + self._settings.setSetting("passive_port_end", p_end) + self._settings.commit() + + restarted = False + if self._running: + await self.stop_server() + res = await self.start_server() + if not res.get("success"): + return { + "success": False, + "error": f"Saved, but restart failed: {res.get('error')}", + } + restarted = True + + return {"success": True, "restarted": restarted} + except Exception as exc: + decky.logger.error("decky-ftpd: save_settings failed — %s", exc) + return {"success": False, "error": str(exc)} diff --git a/src/SettingsModal.tsx b/src/SettingsModal.tsx new file mode 100644 index 0000000..fd788da --- /dev/null +++ b/src/SettingsModal.tsx @@ -0,0 +1,129 @@ +import { ButtonItem, ModalRoot, TextField } from "@decky/ui"; +import { callable, toaster } from "@decky/api"; +import { useEffect, useState } from "react"; + +interface FtpdSettings { + port: number; + root_dir: string; + passive_port_start: number; + passive_port_end: number; +} + +const DEFAULTS: FtpdSettings = { + port: 2121, + root_dir: "/", + passive_port_start: 50000, + passive_port_end: 50100, +}; + +const getSettings = callable<[], FtpdSettings>("get_settings"); +const saveSettings = callable< + [Record], + { success: boolean; error?: string; restarted?: boolean } +>("save_settings"); + +interface Props { + closeModal?: () => void; +} + +export default function SettingsModal({ closeModal }: Props) { + const [portStr, setPortStr] = useState(String(DEFAULTS.port)); + const [rootDir, setRootDir] = useState(DEFAULTS.root_dir); + const [passStartStr, setPassStartStr] = useState( + String(DEFAULTS.passive_port_start), + ); + const [passEndStr, setPassEndStr] = useState( + String(DEFAULTS.passive_port_end), + ); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + getSettings() + .then((s) => { + const cur = s ?? DEFAULTS; + setPortStr(String(cur.port)); + setRootDir(cur.root_dir); + setPassStartStr(String(cur.passive_port_start)); + setPassEndStr(String(cur.passive_port_end)); + }) + .catch((e) => console.error("[decky-ftpd] get_settings failed", e)) + .finally(() => setLoading(false)); + }, []); + + const onSave = async () => { + setSaving(true); + try { + const res = await saveSettings({ + port: portStr, + root_dir: rootDir.trim(), + passive_port_start: passStartStr, + passive_port_end: passEndStr, + }); + if (res.success) { + toaster.toast({ + title: "decky-ftpd", + body: res.restarted + ? "Settings saved. Server restarted." + : "Settings saved.", + }); + closeModal?.(); + } else { + toaster.toast({ + title: "decky-ftpd", + body: res.error ?? "Failed to save.", + }); + } + } catch (e) { + console.error("[decky-ftpd] save_settings failed", e); + toaster.toast({ title: "decky-ftpd", body: "Failed to save settings." }); + } finally { + setSaving(false); + } + }; + + return ( + +
+ decky-ftpd settings +
+ + {loading ? ( +
Loading…
+ ) : ( + <> + setPortStr(e.target.value)} + /> + setRootDir(e.target.value)} + /> + setPassStartStr(e.target.value)} + /> + setPassEndStr(e.target.value)} + /> + +
+ + {saving ? "Saving…" : "Save & Restart Server"} + +
+ + )} +
+ ); +} diff --git a/src/index.tsx b/src/index.tsx index f40507c..de0b832 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ import { ToggleField, Field, staticClasses, + showModal, } from "@decky/ui"; import { addEventListener, @@ -15,25 +16,24 @@ import { } from "@decky/api"; import { useState, useEffect, useCallback } from "react"; import { FaNetworkWired } from "react-icons/fa"; +import SettingsModal from "./SettingsModal"; + +interface FtpdStatus { + running: boolean; + ip: string; + port: number; + root: string; +} + +const getStatus = callable<[], FtpdStatus>("get_status"); -// ── backend callables ────────────────────────────────────────────────────── const startServer = callable<[], { success: boolean; error?: string }>( "start_server", ); const stopServer = callable<[], { success: boolean; error?: string }>( "stop_server", ); -const getStatus = callable< - [], - { - running: boolean; - ip: string; - port: number; - root: string; - } ->("get_status"); - -// ── status indicator dot ─────────────────────────────────────────────────── + function StatusDot({ running }: { running: boolean }) { return ( (false); const [ip, setIp] = useState(""); const [port, setPort] = useState(21); - const [root, setRoot] = useState("/home/deck"); + const [root, setRoot] = useState("/"); const [toggling, setToggling] = useState(false); - - const refresh = useCallback(async () => { - try { - const s = await getStatus(); - setRunning(s.running); - setIp(s.ip); - setPort(s.port); - setRoot(s.root); - } catch (_) {} + const applyStatus = useCallback((s: FtpdStatus) => { + setRunning(s.running); + setIp(s.ip); + setPort(s.port); + setRoot(s.root); }, []); - // Initial fetch + poll every 5 s while panel is open useEffect(() => { - void refresh(); - const id = setInterval(refresh, 5000); - return () => clearInterval(id); - }, [refresh]); + let cancelled = false; + + getStatus() + .then((s) => { + if (!cancelled) applyStatus(s); + }) + .catch(() => {}); + + const listener = addEventListener<[FtpdStatus]>("ftpd_status", (s) => { + applyStatus(s); + }); + + return () => { + cancelled = true; + removeEventListener("ftpd_status", listener); + }; + }, []); const handleToggle = async (next: boolean) => { setToggling(true); try { const res = next ? await startServer() : await stopServer(); if (res.success) { - await refresh(); toaster.toast({ title: "decky-ftpd", body: next ? "FTP server started" : "FTP server stopped", @@ -166,13 +171,9 @@ function Content() { { - // TODO: Navigation.Navigate("/decky-ftpd/settings"); - // Navigation.CloseSideMenus(); - }} + onClick={() => showModal()} > - Settings (coming soon) + Settings @@ -180,17 +181,8 @@ function Content() { ); } -// ── plugin entry point ───────────────────────────────────────────────────── export default definePlugin(() => { console.log("decky-ftpd: frontend loaded"); - - const listener = addEventListener<[status: string]>( - "ftpd_status", - (status) => { - console.log("decky-ftpd got ftpd_status:", status); - }, - ); - return { name: "decky-ftpd", titleView:
decky-ftpd
, @@ -198,7 +190,6 @@ export default definePlugin(() => { icon: , onDismount() { console.log("decky-ftpd: frontend unloading"); - removeEventListener("ftpd_status", listener); }, }; });