Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Thumbs.db
# Ignore built ts files
dist/
decky-ftpd-dist/
decky-ftpd.zip

__pycache__/

Expand Down
Binary file removed decky-ftpd.zip
Binary file not shown.
153 changes: 135 additions & 18 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import warnings
from typing import TYPE_CHECKING

from settings import SettingsManager # pyright: ignore[reportMissingImports]

import decky

if TYPE_CHECKING:
Expand Down Expand Up @@ -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):
Expand All @@ -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}
Expand All @@ -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}
Expand All @@ -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)}
129 changes: 129 additions & 0 deletions src/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | number>],
{ 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 (
<ModalRoot onCancel={closeModal} onEscKeypress={closeModal}>
<div style={{ fontSize: 18, fontWeight: "bold", marginBottom: 12 }}>
decky-ftpd settings
</div>

{loading ? (
<div>Loading…</div>
) : (
<>
<TextField
label="Port"
description="Control connection port. Default 2121. Must be ≥ 1024."
value={portStr}
onChange={(e) => setPortStr(e.target.value)}
/>
<TextField
label="Root directory"
description="Absolute path exposed over FTP. Default / (full filesystem)."
value={rootDir}
onChange={(e) => setRootDir(e.target.value)}
/>
<TextField
label="Passive port start"
description="Default 50000."
value={passStartStr}
onChange={(e) => setPassStartStr(e.target.value)}
/>
<TextField
label="Passive port end"
description="Default 50100."
value={passEndStr}
onChange={(e) => setPassEndStr(e.target.value)}
/>

<div style={{ marginTop: 16 }}>
<ButtonItem layout="below" disabled={saving} onClick={onSave}>
{saving ? "Saving…" : "Save & Restart Server"}
</ButtonItem>
</div>
</>
)}
</ModalRoot>
);
}
Loading
Loading