From b44d424e580e2edc58709654663adac973aa71a8 Mon Sep 17 00:00:00 2001 From: codevski <1435321+codevski@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:15:48 +1000 Subject: [PATCH 1/3] fix(backend): preserve unspecified settings on partial save feat(ui): add quick paths section to QAM panel --- main.py | 34 ++++++++++++++++++++++++++++++--- src/index.tsx | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 9336868..bc04bfd 100644 --- a/main.py +++ b/main.py @@ -139,8 +139,17 @@ def _serve(): self._server_thread = threading.Thread(target=_serve, daemon=True) self._server_thread.start() + + self._server_thread.join(timeout=0.2) + if not self._server_thread.is_alive(): + return { + "success": False, + "error": "Server failed to start (check port availability).", + } + self._running = True await self._emit_status() + return {"success": True} return {"success": True} @@ -185,19 +194,36 @@ 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"])) + port = int(new_settings.get("port", self._get("port"))) + root = str(new_settings.get("root_dir", self._get("root_dir"))) + p_start = int( + new_settings.get("passive_port_start", self._get("passive_port_start")) + ) + p_end = int( + new_settings.get("passive_port_end", self._get("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": "Root must be an absolute path.", + "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 @@ -210,6 +236,8 @@ async def save_settings(self, new_settings: dict) -> dict: "error": f"Saved, but restart failed: {res.get('error')}", } restarted = True + else: + await self._emit_status() return {"success": True, "restarted": restarted} except Exception as exc: diff --git a/src/index.tsx b/src/index.tsx index de0b832..c886de7 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,6 +25,12 @@ interface FtpdStatus { root: string; } +const QUICK_PATHS = [ + { label: "Home", path: "/home/deck" }, + { label: "SD Card", path: "/run/media" }, + { label: "Everything", path: "/" }, +]; + const getStatus = callable<[], FtpdStatus>("get_status"); const startServer = callable<[], { success: boolean; error?: string }>( @@ -76,12 +82,34 @@ function Content() { const [port, setPort] = useState(21); const [root, setRoot] = useState("/"); const [toggling, setToggling] = useState(false); + const [savingPath, setSavingPath] = useState(null); + const applyStatus = useCallback((s: FtpdStatus) => { setRunning(s.running); setIp(s.ip); setPort(s.port); setRoot(s.root); }, []); + const saveSettings = callable< + [Record], + { success: boolean; error?: string; restarted?: boolean } + >("save_settings"); + + const handleQuickPath = async (path: string) => { + if (path === root || savingPath !== null) return; + setSavingPath(path); + try { + const res = await saveSettings({ root_dir: path }); + if (!res.success) { + toaster.toast({ + title: "decky-ftpd — error", + body: res.error ?? "Failed to change path", + }); + } + } finally { + setSavingPath(null); + } + }; useEffect(() => { let cancelled = false; @@ -166,11 +194,34 @@ function Content() { )} + + {QUICK_PATHS.map((qp) => { + const active = root === qp.path; + return ( + + + {qp.path} + + } + onClick={() => handleQuickPath(qp.path)} + > + {active ? "✓ " : ""} + {qp.label} + + + ); + })} + + showModal()} > Settings From ee2741ce48189f54562fb88636ef6df6f6ad4d11 Mon Sep 17 00:00:00 2001 From: codevski <1435321+codevski@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:16:29 +1000 Subject: [PATCH 2/3] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fba2b10..f2c77cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented here. +## [0.1.2] - 2026-04-18 + +### Added +- Quick Paths section in QAM with one-tap switching between Home, SD Card, and full filesystem + +### Fixed +- Partial settings updates no longer reset unspecified fields to defaults + ## [0.1.1] - 2026-04-18 ### Added From 4265ef2e5d11e4729c473ca8118e341c7ace8ced Mon Sep 17 00:00:00 2001 From: codevski <1435321+codevski@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:22:44 +1000 Subject: [PATCH 3/3] Extract Python utils and add frontend types --- main.py | 27 +++++---------------------- src/SettingsModal.tsx | 12 ++---------- src/backend.ts | 11 +++++++++++ src/defaults.ts | 14 ++++++++++++++ src/index.tsx | 25 +++---------------------- src/types.ts | 25 +++++++++++++++++++++++++ utils.py | 21 +++++++++++++++++++++ 7 files changed, 81 insertions(+), 54 deletions(-) create mode 100644 src/backend.ts create mode 100644 src/defaults.ts create mode 100644 src/types.ts create mode 100644 utils.py diff --git a/main.py b/main.py index bc04bfd..c734115 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ from settings import SettingsManager # pyright: ignore[reportMissingImports] import decky +from utils import ensure_pyftpdlib, get_local_ip if TYPE_CHECKING: from pyftpdlib.servers import FTPServer @@ -16,22 +17,6 @@ PY_MODULES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "py_modules") -def _ensure_pyftpdlib() -> None: - if PY_MODULES_DIR not in sys.path: - sys.path.insert(0, PY_MODULES_DIR) - - -def _get_local_ip() -> str: - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() - return ip - except Exception: - return "Unknown" - - class Plugin: _server: "FTPServer | None" = None _server_thread = None @@ -48,7 +33,7 @@ class Plugin: async def _main(self): self._loop = asyncio.get_running_loop() - _ensure_pyftpdlib() + ensure_pyftpdlib() settings = SettingsManager( name="settings", @@ -79,7 +64,7 @@ async def _emit_status(self): "ftpd_status", { "running": self._running, - "ip": _get_local_ip() if self._running else "", + "ip": get_local_ip() if self._running else "", "port": self._get("port"), "root": self._get("root_dir"), }, @@ -92,7 +77,7 @@ async def start_server(self) -> dict: return {"success": True, "already": True} try: - _ensure_pyftpdlib() + ensure_pyftpdlib() from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer @@ -151,8 +136,6 @@ def _serve(): await self._emit_status() return {"success": True} - return {"success": True} - except Exception as exc: self._running = False await self._emit_status() @@ -178,7 +161,7 @@ async def stop_server(self) -> dict: async def get_status(self) -> dict: return { "running": self._running, - "ip": _get_local_ip() if self._running else "", + "ip": get_local_ip() if self._running else "", "port": self._get("port"), "root": self._get("root_dir"), } diff --git a/src/SettingsModal.tsx b/src/SettingsModal.tsx index b1e657f..23977d9 100644 --- a/src/SettingsModal.tsx +++ b/src/SettingsModal.tsx @@ -1,16 +1,8 @@ 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; -} - -const DEFAULTS: FtpdSettings = { - port: 2121, - root_dir: "/", -}; +import { FtpdSettings } from "./types"; +import { DEFAULTS } from "./defaults"; const getSettings = callable<[], FtpdSettings>("get_settings"); const saveSettings = callable< diff --git a/src/backend.ts b/src/backend.ts new file mode 100644 index 0000000..6b9440d --- /dev/null +++ b/src/backend.ts @@ -0,0 +1,11 @@ +import { callable } from "@decky/api"; +import { FtpdSettings, FtpdStatus, SaveResult, ToggleResult } from "./types"; + +export const startServer = callable<[], ToggleResult>("start_server"); +export const stopServer = callable<[], ToggleResult>("stop_server"); +export const getStatus = callable<[], FtpdStatus>("get_status"); +export const getSettings = callable<[], FtpdSettings>("get_settings"); +export const saveSettings = callable< + [Record], + SaveResult +>("save_settings"); diff --git a/src/defaults.ts b/src/defaults.ts new file mode 100644 index 0000000..d1f7bb5 --- /dev/null +++ b/src/defaults.ts @@ -0,0 +1,14 @@ +import { FtpdSettings } from "./types"; + +export const DEFAULTS: FtpdSettings = { + port: 2121, + root_dir: "/", + passive_port_start: 50000, + passive_port_end: 50100, +}; + +export const QUICK_PATHS: Array<{ label: string; path: string }> = [ + { label: "Home", path: "/home/deck" }, + { label: "SD Card", path: "/run/media" }, + { label: "Everything", path: "/" }, +]; diff --git a/src/index.tsx b/src/index.tsx index c886de7..de97e00 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,28 +17,9 @@ import { 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 QUICK_PATHS = [ - { label: "Home", path: "/home/deck" }, - { label: "SD Card", path: "/run/media" }, - { label: "Everything", path: "/" }, -]; - -const getStatus = callable<[], FtpdStatus>("get_status"); - -const startServer = callable<[], { success: boolean; error?: string }>( - "start_server", -); -const stopServer = callable<[], { success: boolean; error?: string }>( - "stop_server", -); +import { FtpdStatus } from "./types"; +import { getStatus, startServer, stopServer } from "./backend"; +import { QUICK_PATHS } from "./defaults"; function StatusDot({ running }: { running: boolean }) { return ( diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9cc0904 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,25 @@ +export interface FtpdStatus { + running: boolean; + ip: string; + port: number; + root: string; +} + +export interface FtpdSettings { + port: number; + root_dir: string; + passive_port_start: number; + passive_port_end: number; +} + +export interface SaveResult { + success: boolean; + error?: string; + restarted?: boolean; +} + +export interface ToggleResult { + success: boolean; + error?: string; + already?: boolean; +} diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..20129d9 --- /dev/null +++ b/utils.py @@ -0,0 +1,21 @@ +import os +import socket +import sys + +PY_MODULES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "py_modules") + + +def ensure_pyftpdlib() -> None: + if PY_MODULES_DIR not in sys.path: + sys.path.insert(0, PY_MODULES_DIR) + + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "Unknown"