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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 35 additions & 24 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,14 @@
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

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
Expand All @@ -48,7 +33,7 @@ class Plugin:

async def _main(self):
self._loop = asyncio.get_running_loop()
_ensure_pyftpdlib()
ensure_pyftpdlib()

settings = SettingsManager(
name="settings",
Expand Down Expand Up @@ -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"),
},
Expand All @@ -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
Expand Down Expand Up @@ -139,9 +124,16 @@ 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}

except Exception as exc:
Expand Down Expand Up @@ -169,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"),
}
Expand All @@ -185,19 +177,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
Expand All @@ -210,6 +219,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:
Expand Down
12 changes: 2 additions & 10 deletions src/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -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<
Expand Down
11 changes: 11 additions & 0 deletions src/backend.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number>],
SaveResult
>("save_settings");
14 changes: 14 additions & 0 deletions src/defaults.ts
Original file line number Diff line number Diff line change
@@ -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: "/" },
];
66 changes: 49 additions & 17 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +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 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 (
Expand Down Expand Up @@ -76,12 +63,34 @@ function Content() {
const [port, setPort] = useState<number>(21);
const [root, setRoot] = useState<string>("/");
const [toggling, setToggling] = useState<boolean>(false);
const [savingPath, setSavingPath] = useState<string | null>(null);

const applyStatus = useCallback((s: FtpdStatus) => {
setRunning(s.running);
setIp(s.ip);
setPort(s.port);
setRoot(s.root);
}, []);
const saveSettings = callable<
[Record<string, string | number>],
{ 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;
Expand Down Expand Up @@ -166,11 +175,34 @@ function Content() {
)}
</PanelSection>

<PanelSection title="Quick Paths">
{QUICK_PATHS.map((qp) => {
const active = root === qp.path;
return (
<PanelSectionRow key={qp.path}>
<ButtonItem
layout="below"
disabled={savingPath !== null}
description={
<span style={{ fontFamily: "monospace", fontSize: 11 }}>
{qp.path}
</span>
}
onClick={() => handleQuickPath(qp.path)}
>
{active ? "✓ " : ""}
{qp.label}
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>

<PanelSection title="Options">
<PanelSectionRow>
<ButtonItem
layout="below"
description="Port, root directory, passive range…"
description="Port, root directory, authentication"
onClick={() => showModal(<SettingsModal />)}
>
Settings
Expand Down
25 changes: 25 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions utils.py
Original file line number Diff line number Diff line change
@@ -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"
Loading