66import warnings
77from typing import TYPE_CHECKING
88
9+ from settings import SettingsManager # pyright: ignore[reportMissingImports]
10+
911import decky
1012
1113if 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 )}
0 commit comments