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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to this project will be documented here.

## [0.1.4] - 2026-05-20

### Added
- Username/password authentication, on by default with credentials `deck` / `deck` (editable in Settings).
- Anonymous (no-password) mode as an opt-in toggle, gated by a confirmation prompt.
- Login row on the main panel showing the active username (or "anonymous").

### Changed
- Root directory is now locked to `/`. Navigate to subdirectories from your FTP client. This sidesteps a "no access" bug seen when the configured root resolved through a symlink (notably `/run/media` for the SD card).
- Settings modal: removed the Root directory field; added Username, Password, and Anonymous access controls.
- Quick Paths panel removed from the main screen.
- Settings moved from a bottom button to a gear icon in the panel title bar. The Options section is gone; the QAM panel is now action-only.
- Login and Sharing rows merged into a single line (`<user> · /`) to tighten the FTP Server section.

### Migration
- Existing installations with a custom `root_dir` setting will start serving `/` after upgrade. Authentication is on by default, so existing users automatically gain credential protection.

## [0.1.3] - 2026-04-18

### Fixed
Expand Down
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
* 🔗 **Instant connect** — your Deck's local IP and port are displayed right in the panel
* 📁 **Full read/write access** to `/`
- Games, saves, emulators, homebrew all transferable.
* 🔌 **Zero config** — anonymous login, no credentials to set up
* 🔒 **Authentication on by default** — default credentials `deck` / `deck`, fully editable in Settings. Anonymous mode is opt-in.
* ⚡ **Fully offline** — no internet required on the Deck after install
* 🛡️ **Local network only** — never exposed to the public internet

Expand All @@ -52,13 +52,15 @@

### Connecting

| Field | Value |
|----------|------------------------------|
| Protocol | FTP (not SFTP or FTP-SSL) |
| Host | IP shown in the QAM panel |
| Port | `2121` |
| Username | `anonymous` (or leave blank) |
| Password | *(anything or empty)* |
| Field | Value |
|----------|-------------------------------------------|
| Protocol | FTP (not SFTP or FTP-SSL) |
| Host | IP shown in the QAM panel |
| Port | `2121` |
| Username | `deck` (default — change in Settings) |
| Password | `deck` (default — change in Settings) |

> Prefer no-password access on a trusted home network? Enable **Anonymous access** in Settings and confirm the warning prompt.

### Recommended FTP clients

Expand Down Expand Up @@ -121,10 +123,21 @@ You can [download](https://github.com/codevski/decky-ftpd/releases) the latest r

## Roadmap

- [x] Settings page — custom port, root directory, passive port range
- [ ] Optional username/password auth
- [ ] MicroSD card quick-access shortcut
### Server
- [x] Settings page, custom port, passive port range
- [x] Username/password authentication (default on, anonymous opt-in)
- [ ] Active connection count in the status line
- [ ] Configurable root directory (reintroduce safely once the symlink edge cases are sorted)

### Client (PSP/3DS Sync)
- [ ] Pull mode connect to a remote FTP server and mirror a path locally
- [ ] Configurable remote IP, remote path, and local destination
- [ ] Remembered last-used remote IP (persisted via settingsManager)
- [ ] Progress feedback and sync log in QAM
- [ ] Auto-detect SD card destinations for sync target

### Future
- [ ] Steam Machine compatibility (dynamic mount point detection)

## Credits

Expand Down
63 changes: 52 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

PY_MODULES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "py_modules")

FTP_ROOT = "/"


class Plugin:
_server: "FTPServer | None" = None
Expand All @@ -27,9 +29,11 @@ class Plugin:

DEFAULTS = {
"port": 2121,
"root_dir": "/",
"passive_port_start": 50000,
"passive_port_end": 50100,
"username": "deck",
"password": "deck",
"anonymous": False,
}

async def _main(self):
Expand All @@ -44,9 +48,10 @@ async def _main(self):
self._settings = settings

decky.logger.info(
"decky-ftpd loaded (port=%d, root=%s)",
"decky-ftpd loaded (port=%d, root=%s, anonymous=%s)",
self._get("port"),
self._get("root_dir"),
FTP_ROOT,
self._get("anonymous"),
)

async def _unload(self):
Expand All @@ -67,7 +72,9 @@ async def _emit_status(self):
"running": self._running,
"ip": get_local_ip() if self._running else "",
"port": self._get("port"),
"root": self._get("root_dir"),
"root": FTP_ROOT,
"username": self._get("username"),
"anonymous": bool(self._get("anonymous")),
},
)
except Exception as e:
Expand All @@ -83,10 +90,25 @@ async def start_server(self) -> dict:
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer

anonymous = bool(self._get("anonymous"))
username = str(self._get("username") or "").strip()
password = str(self._get("password") or "")

with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
authorizer = DummyAuthorizer()
authorizer.add_anonymous(self._get("root_dir"), perm="elradfmwMT")
if anonymous:
authorizer.add_anonymous(FTP_ROOT, perm="elradfmwMT")
else:
if not username or not password:
return {
"success": False,
"error": (
"Username and password must be set, or enable "
"anonymous mode in settings."
),
}
authorizer.add_user(username, password, FTP_ROOT, perm="elradfmwMT")

p_start = self._get("passive_port_start")
p_end = self._get("passive_port_end")
Expand All @@ -105,7 +127,9 @@ class DeckFTPHandler(FTPHandler):

def _serve():
decky.logger.info(
"decky-ftpd: server started on port %d", self._get("port")
"decky-ftpd: server started on port %d (anonymous=%s)",
self._get("port"),
anonymous,
)
try:
server.serve_forever()
Expand Down Expand Up @@ -164,7 +188,9 @@ async def get_status(self) -> dict:
"running": self._running,
"ip": get_local_ip() if self._running else "",
"port": self._get("port"),
"root": self._get("root_dir"),
"root": FTP_ROOT,
"username": self._get("username"),
"anonymous": bool(self._get("anonymous")),
}

def _get(self, key: str):
Expand All @@ -179,18 +205,20 @@ async def save_settings(self, new_settings: dict) -> dict:
assert self._settings is not None

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"))
)
anonymous = bool(new_settings.get("anonymous", self._get("anonymous")))
username = str(
new_settings.get("username", self._get("username") or "")
).strip()
password = str(new_settings.get("password", self._get("password") or ""))

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:
Expand All @@ -203,11 +231,24 @@ async def save_settings(self, new_settings: dict) -> dict:
"success": False,
"error": "Control port must not sit inside the passive range.",
}
if not anonymous:
if not username:
return {
"success": False,
"error": "Username cannot be empty when anonymous mode is off.",
}
if not password:
return {
"success": False,
"error": "Password cannot be empty when anonymous mode is off.",
}

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.setSetting("anonymous", anonymous)
self._settings.setSetting("username", username)
self._settings.setSetting("password", password)
self._settings.commit()

restarted = False
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "decky-ftpd",
"version": "0.1.3",
"version": "0.1.4",
"description": "FTP server for Steam Deck Game Mode, no desktop required",
"type": "module",
"scripts": {
Expand Down
93 changes: 82 additions & 11 deletions src/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { ButtonItem, ModalRoot, TextField } from "@decky/ui";
import {
ButtonItem,
ConfirmModal,
ModalRoot,
TextField,
ToggleField,
showModal,
} from "@decky/ui";
import { callable, toaster } from "@decky/api";
import { useEffect, useState } from "react";
import { FtpdSettings } from "./types";
import { DEFAULTS } from "./defaults";

const getSettings = callable<[], FtpdSettings>("get_settings");
const saveSettings = callable<
[Record<string, string | number>],
[Record<string, string | number | boolean>],
{ success: boolean; error?: string; restarted?: boolean }
>("save_settings");

Expand All @@ -16,28 +23,64 @@ interface Props {

export default function SettingsModal({ closeModal }: Props) {
const [portStr, setPortStr] = useState(String(DEFAULTS.port));
const [rootDir, setRootDir] = useState(DEFAULTS.root_dir);
const [username, setUsername] = useState(DEFAULTS.username);
const [password, setPassword] = useState(DEFAULTS.password);
const [anonymous, setAnonymous] = useState(DEFAULTS.anonymous);
const [anonToggleKey, setAnonToggleKey] = useState(0);

const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);

useEffect(() => {
getSettings()
.then((s) => {
const cur = s ?? DEFAULTS;
.then((setting) => {
const cur = setting ?? DEFAULTS;
setPortStr(String(cur.port));
setRootDir(cur.root_dir);
setUsername(cur.username ?? DEFAULTS.username);
setPassword(cur.password ?? DEFAULTS.password);
setAnonymous(Boolean(cur.anonymous));
})
.catch((e) => console.error("[decky-ftpd] get_settings failed", e))
.finally(() => setLoading(false));
}, []);

const handleAnonymousToggle = (next: boolean) => {
if (!next) {
setAnonymous(false);
return;
}
const resetVisualToggle = () => {
setAnonymous(false);
setAnonToggleKey((k) => k + 1);
};
showModal(
<ConfirmModal
strTitle="Disable authentication?"
strDescription={
"Anonymous mode means anyone on the same Wi-Fi network can read, " +
"write, and delete files on your Steam Deck without a password. " +
"Only enable this on a trusted home network you control.\n\n" +
"Continue?"
}
strOKButtonText="Enable anonymous"
strCancelButtonText="Cancel"
bDestructiveWarning
bAlertDialog
onOK={() => setAnonymous(true)}
onCancel={resetVisualToggle}
onEscKeypress={resetVisualToggle}
/>,
);
};

const onSave = async () => {
setSaving(true);
try {
const res = await saveSettings({
port: portStr,
root_dir: rootDir.trim(),
username: username.trim(),
password,
anonymous,
});
if (res.success) {
toaster.toast({
Expand Down Expand Up @@ -77,12 +120,40 @@ export default function SettingsModal({ closeModal }: Props) {
value={portStr}
onChange={(e) => setPortStr(e.target.value)}
/>

<div style={{ marginTop: 16, marginBottom: 4, fontWeight: 600 }}>
Authentication
</div>

<TextField
label="Username"
description="FTP login username. Default 'deck'."
value={username}
disabled={anonymous}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
label="Root directory"
description="Absolute path exposed over FTP. Default / (full filesystem)."
value={rootDir}
onChange={(e) => setRootDir(e.target.value)}
label="Password"
description="FTP login password. Default 'deck' — change this if you share a network."
value={password}
disabled={anonymous}
bIsPassword
onChange={(e) => setPassword(e.target.value)}
/>
<ToggleField
key={`anon-toggle-${anonToggleKey}`}
label="Anonymous access (no password)"
description={
<span style={{ color: anonymous ? "#fca5a5" : "#94a3b8" }}>
{anonymous
? "⚠ Anyone on your network can connect with no password and full read/write access. Only use on a trusted home network."
: "Off: a username and password are required to connect. Recommended."}
</span>
}
checked={anonymous}
onChange={handleAnonymousToggle}
/>

<div style={{ marginTop: 16 }}>
<ButtonItem layout="below" disabled={saving} onClick={onSave}>
{saving ? "Saving…" : "Save & Restart Server"}
Expand Down
10 changes: 3 additions & 7 deletions src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,9 @@ import { FtpdSettings } from "./types";

export const DEFAULTS: FtpdSettings = {
port: 2121,
root_dir: "/",
passive_port_start: 50000,
passive_port_end: 50100,
username: "deck",
password: "deck",
anonymous: false,
};

export const QUICK_PATHS: Array<{ label: string; path: string }> = [
{ label: "Home", path: "/home/deck" },
{ label: "SD Card", path: "/run/media" },
{ label: "Everything", path: "/" },
];
Loading
Loading