Skip to content

Commit 126e4d7

Browse files
authored
Merge pull request #1 from codevski/feat/settings-ui
Settings UI
2 parents 209ffa0 + 7a7abd0 commit 126e4d7

5 files changed

Lines changed: 302 additions & 64 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Thumbs.db
3535
# Ignore built ts files
3636
dist/
3737
decky-ftpd-dist/
38+
decky-ftpd.zip
3839

3940
__pycache__/
4041

decky-ftpd.zip

-709 KB
Binary file not shown.

main.py

Lines changed: 135 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import warnings
77
from typing import TYPE_CHECKING
88

9+
from settings import SettingsManager # pyright: ignore[reportMissingImports]
10+
911
import decky
1012

1113
if TYPE_CHECKING:
@@ -34,17 +36,31 @@ class Plugin:
3436
_server: "FTPServer | None" = None
3537
_server_thread = None
3638
_running = False
39+
_settings: "SettingsManager | None" = None
40+
_loop: "asyncio.AbstractEventLoop | None" = None
3741

38-
# TODO: Set in settings page
39-
_port: int = 2121
40-
_root: str = decky.DECKY_USER_HOME
42+
DEFAULTS = {
43+
"port": 2121,
44+
"root_dir": "/",
45+
"passive_port_start": 50000,
46+
"passive_port_end": 50100,
47+
}
4148

42-
# ── lifecycle ──────────────────────────────────────────────────────────
4349
async def _main(self):
44-
self.loop = asyncio.get_event_loop()
50+
self._loop = asyncio.get_running_loop()
4551
_ensure_pyftpdlib()
52+
53+
settings = SettingsManager(
54+
name="settings",
55+
settings_directory=decky.DECKY_PLUGIN_SETTINGS_DIR,
56+
)
57+
settings.read()
58+
self._settings = settings
59+
4660
decky.logger.info(
47-
"decky-ftpd loaded (port=%d, root=%s)", self._port, self._root
61+
"decky-ftpd loaded (port=%d, root=%s)",
62+
self._get("port"),
63+
self._get("root_dir"),
4864
)
4965

5066
async def _unload(self):
@@ -55,9 +71,22 @@ async def _uninstall(self):
5571
decky.logger.info("decky-ftpd uninstalled")
5672

5773
async def _migration(self):
58-
decky.logger.info("decky-ftpd migration (nothing to migrate yet)")
74+
decky.logger.info("decky-ftpd: nothing to migrate")
75+
76+
async def _emit_status(self):
77+
try:
78+
await decky.emit(
79+
"ftpd_status",
80+
{
81+
"running": self._running,
82+
"ip": _get_local_ip() if self._running else "",
83+
"port": self._get("port"),
84+
"root": self._get("root_dir"),
85+
},
86+
)
87+
except Exception as e:
88+
decky.logger.warning("decky-ftpd: emit failed — %s", e)
5989

60-
# ── callable: start ────────────────────────────────────────────────────
6190
async def start_server(self) -> dict:
6291
if self._running:
6392
return {"success": True, "already": True}
@@ -71,35 +100,56 @@ async def start_server(self) -> dict:
71100
with warnings.catch_warnings():
72101
warnings.simplefilter("ignore", RuntimeWarning)
73102
authorizer = DummyAuthorizer()
74-
authorizer.add_anonymous(self._root, perm="elradfmwMT")
103+
authorizer.add_anonymous(self._get("root_dir"), perm="elradfmwMT")
104+
105+
p_start = self._get("passive_port_start")
106+
p_end = self._get("passive_port_end")
75107

76108
class DeckFTPHandler(FTPHandler):
77-
passive_ports = range(50000, 50100)
109+
passive_ports = range(p_start, p_end)
78110
banner = "Steam Deck FTP ready."
79111

80-
DeckFTPHandler.authorizer = authorizer # ← this was missing!
112+
DeckFTPHandler.authorizer = authorizer
81113

82-
self._server = FTPServer(("0.0.0.0", self._port), DeckFTPHandler)
114+
self._server = FTPServer(("0.0.0.0", self._get("port")), DeckFTPHandler)
83115
self._server.max_cons = 10
84116
self._server.max_cons_per_ip = 3
85117

86118
server = self._server
87119

88120
def _serve():
89-
decky.logger.info("decky-ftpd: server started on port %d", self._port)
90-
server.serve_forever()
121+
decky.logger.info(
122+
"decky-ftpd: server started on port %d", self._get("port")
123+
)
124+
try:
125+
server.serve_forever()
126+
except Exception as exc:
127+
decky.logger.error("decky-ftpd: server thread crashed — %s", exc)
128+
finally:
129+
if self._server is server:
130+
self._running = False
131+
loop = self._loop
132+
if loop is not None:
133+
try:
134+
asyncio.run_coroutine_threadsafe(
135+
self._emit_status(), loop
136+
)
137+
except Exception:
138+
pass
91139

92140
self._server_thread = threading.Thread(target=_serve, daemon=True)
93141
self._server_thread.start()
94142
self._running = True
143+
await self._emit_status()
95144

96145
return {"success": True}
97146

98147
except Exception as exc:
148+
self._running = False
149+
await self._emit_status()
99150
decky.logger.error("decky-ftpd: failed to start — %s", exc)
100151
return {"success": False, "error": str(exc)}
101152

102-
# ── callable: stop ─────────────────────────────────────────────────────
103153
async def stop_server(self) -> dict:
104154
if not self._running:
105155
return {"success": True, "already": True}
@@ -109,17 +159,84 @@ async def stop_server(self) -> dict:
109159
self._server.close_all()
110160
self._server = None
111161
self._running = False
162+
await self._emit_status()
112163
decky.logger.info("decky-ftpd: server stopped")
113164
return {"success": True}
114165
except Exception as exc:
115166
decky.logger.error("decky-ftpd: failed to stop — %s", exc)
116167
return {"success": False, "error": str(exc)}
117168

118-
# ── callable: status ───────────────────────────────────────────────────
119169
async def get_status(self) -> dict:
120170
return {
121171
"running": self._running,
122172
"ip": _get_local_ip() if self._running else "",
123-
"port": self._port,
124-
"root": self._root,
173+
"port": self._get("port"),
174+
"root": self._get("root_dir"),
125175
}
176+
177+
def _get(self, key: str):
178+
assert self._settings is not None
179+
return self._settings.getSetting(key, self.DEFAULTS[key])
180+
181+
async def get_settings(self) -> dict:
182+
return {k: self._get(k) for k in self.DEFAULTS}
183+
184+
async def save_settings(self, new_settings: dict) -> dict:
185+
try:
186+
assert self._settings is not None
187+
188+
port = int(new_settings.get("port", self.DEFAULTS["port"]))
189+
root = str(new_settings.get("root_dir", self.DEFAULTS["root_dir"]))
190+
p_start = int(
191+
new_settings.get(
192+
"passive_port_start", self.DEFAULTS["passive_port_start"]
193+
)
194+
)
195+
p_end = int(
196+
new_settings.get("passive_port_end", self.DEFAULTS["passive_port_end"])
197+
)
198+
199+
if not (1024 <= port <= 65535):
200+
return {"success": False, "error": "Port must be 1024–65535."}
201+
if not root.startswith("/"):
202+
return {
203+
"success": False,
204+
"error": "Root must be an absolute path.",
205+
}
206+
if not (1024 <= p_start <= 65535 and 1024 <= p_end <= 65535):
207+
return {
208+
"success": False,
209+
"error": "Passive ports must be 1024–65535.",
210+
}
211+
if p_end <= p_start:
212+
return {
213+
"success": False,
214+
"error": "Passive end must be greater than start.",
215+
}
216+
if p_start <= port <= p_end:
217+
return {
218+
"success": False,
219+
"error": "Control port must not sit inside the passive range.",
220+
}
221+
222+
self._settings.setSetting("port", port)
223+
self._settings.setSetting("root_dir", root)
224+
self._settings.setSetting("passive_port_start", p_start)
225+
self._settings.setSetting("passive_port_end", p_end)
226+
self._settings.commit()
227+
228+
restarted = False
229+
if self._running:
230+
await self.stop_server()
231+
res = await self.start_server()
232+
if not res.get("success"):
233+
return {
234+
"success": False,
235+
"error": f"Saved, but restart failed: {res.get('error')}",
236+
}
237+
restarted = True
238+
239+
return {"success": True, "restarted": restarted}
240+
except Exception as exc:
241+
decky.logger.error("decky-ftpd: save_settings failed — %s", exc)
242+
return {"success": False, "error": str(exc)}

src/SettingsModal.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { ButtonItem, ModalRoot, TextField } from "@decky/ui";
2+
import { callable, toaster } from "@decky/api";
3+
import { useEffect, useState } from "react";
4+
5+
interface FtpdSettings {
6+
port: number;
7+
root_dir: string;
8+
passive_port_start: number;
9+
passive_port_end: number;
10+
}
11+
12+
const DEFAULTS: FtpdSettings = {
13+
port: 2121,
14+
root_dir: "/",
15+
passive_port_start: 50000,
16+
passive_port_end: 50100,
17+
};
18+
19+
const getSettings = callable<[], FtpdSettings>("get_settings");
20+
const saveSettings = callable<
21+
[Record<string, string | number>],
22+
{ success: boolean; error?: string; restarted?: boolean }
23+
>("save_settings");
24+
25+
interface Props {
26+
closeModal?: () => void;
27+
}
28+
29+
export default function SettingsModal({ closeModal }: Props) {
30+
const [portStr, setPortStr] = useState(String(DEFAULTS.port));
31+
const [rootDir, setRootDir] = useState(DEFAULTS.root_dir);
32+
const [passStartStr, setPassStartStr] = useState(
33+
String(DEFAULTS.passive_port_start),
34+
);
35+
const [passEndStr, setPassEndStr] = useState(
36+
String(DEFAULTS.passive_port_end),
37+
);
38+
const [loading, setLoading] = useState(true);
39+
const [saving, setSaving] = useState(false);
40+
41+
useEffect(() => {
42+
getSettings()
43+
.then((s) => {
44+
const cur = s ?? DEFAULTS;
45+
setPortStr(String(cur.port));
46+
setRootDir(cur.root_dir);
47+
setPassStartStr(String(cur.passive_port_start));
48+
setPassEndStr(String(cur.passive_port_end));
49+
})
50+
.catch((e) => console.error("[decky-ftpd] get_settings failed", e))
51+
.finally(() => setLoading(false));
52+
}, []);
53+
54+
const onSave = async () => {
55+
setSaving(true);
56+
try {
57+
const res = await saveSettings({
58+
port: portStr,
59+
root_dir: rootDir.trim(),
60+
passive_port_start: passStartStr,
61+
passive_port_end: passEndStr,
62+
});
63+
if (res.success) {
64+
toaster.toast({
65+
title: "decky-ftpd",
66+
body: res.restarted
67+
? "Settings saved. Server restarted."
68+
: "Settings saved.",
69+
});
70+
closeModal?.();
71+
} else {
72+
toaster.toast({
73+
title: "decky-ftpd",
74+
body: res.error ?? "Failed to save.",
75+
});
76+
}
77+
} catch (e) {
78+
console.error("[decky-ftpd] save_settings failed", e);
79+
toaster.toast({ title: "decky-ftpd", body: "Failed to save settings." });
80+
} finally {
81+
setSaving(false);
82+
}
83+
};
84+
85+
return (
86+
<ModalRoot onCancel={closeModal} onEscKeypress={closeModal}>
87+
<div style={{ fontSize: 18, fontWeight: "bold", marginBottom: 12 }}>
88+
decky-ftpd settings
89+
</div>
90+
91+
{loading ? (
92+
<div>Loading…</div>
93+
) : (
94+
<>
95+
<TextField
96+
label="Port"
97+
description="Control connection port. Default 2121. Must be ≥ 1024."
98+
value={portStr}
99+
onChange={(e) => setPortStr(e.target.value)}
100+
/>
101+
<TextField
102+
label="Root directory"
103+
description="Absolute path exposed over FTP. Default / (full filesystem)."
104+
value={rootDir}
105+
onChange={(e) => setRootDir(e.target.value)}
106+
/>
107+
<TextField
108+
label="Passive port start"
109+
description="Default 50000."
110+
value={passStartStr}
111+
onChange={(e) => setPassStartStr(e.target.value)}
112+
/>
113+
<TextField
114+
label="Passive port end"
115+
description="Default 50100."
116+
value={passEndStr}
117+
onChange={(e) => setPassEndStr(e.target.value)}
118+
/>
119+
120+
<div style={{ marginTop: 16 }}>
121+
<ButtonItem layout="below" disabled={saving} onClick={onSave}>
122+
{saving ? "Saving…" : "Save & Restart Server"}
123+
</ButtonItem>
124+
</div>
125+
</>
126+
)}
127+
</ModalRoot>
128+
);
129+
}

0 commit comments

Comments
 (0)