Skip to content

Commit 9271de0

Browse files
authored
Merge pull request #3 from codevski/feat/quick-paths
Quick Paths
2 parents af9b5b0 + 4265ef2 commit 9271de0

8 files changed

Lines changed: 165 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to this project will be documented here.
44

5+
## [0.1.2] - 2026-04-18
6+
7+
### Added
8+
- Quick Paths section in QAM with one-tap switching between Home, SD Card, and full filesystem
9+
10+
### Fixed
11+
- Partial settings updates no longer reset unspecified fields to defaults
12+
513
## [0.1.1] - 2026-04-18
614

715
### Added

main.py

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,14 @@
99
from settings import SettingsManager # pyright: ignore[reportMissingImports]
1010

1111
import decky
12+
from utils import ensure_pyftpdlib, get_local_ip
1213

1314
if TYPE_CHECKING:
1415
from pyftpdlib.servers import FTPServer
1516

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

1819

19-
def _ensure_pyftpdlib() -> None:
20-
if PY_MODULES_DIR not in sys.path:
21-
sys.path.insert(0, PY_MODULES_DIR)
22-
23-
24-
def _get_local_ip() -> str:
25-
try:
26-
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
27-
s.connect(("8.8.8.8", 80))
28-
ip = s.getsockname()[0]
29-
s.close()
30-
return ip
31-
except Exception:
32-
return "Unknown"
33-
34-
3520
class Plugin:
3621
_server: "FTPServer | None" = None
3722
_server_thread = None
@@ -48,7 +33,7 @@ class Plugin:
4833

4934
async def _main(self):
5035
self._loop = asyncio.get_running_loop()
51-
_ensure_pyftpdlib()
36+
ensure_pyftpdlib()
5237

5338
settings = SettingsManager(
5439
name="settings",
@@ -79,7 +64,7 @@ async def _emit_status(self):
7964
"ftpd_status",
8065
{
8166
"running": self._running,
82-
"ip": _get_local_ip() if self._running else "",
67+
"ip": get_local_ip() if self._running else "",
8368
"port": self._get("port"),
8469
"root": self._get("root_dir"),
8570
},
@@ -92,7 +77,7 @@ async def start_server(self) -> dict:
9277
return {"success": True, "already": True}
9378

9479
try:
95-
_ensure_pyftpdlib()
80+
ensure_pyftpdlib()
9681
from pyftpdlib.authorizers import DummyAuthorizer
9782
from pyftpdlib.handlers import FTPHandler
9883
from pyftpdlib.servers import FTPServer
@@ -139,9 +124,16 @@ def _serve():
139124

140125
self._server_thread = threading.Thread(target=_serve, daemon=True)
141126
self._server_thread.start()
127+
128+
self._server_thread.join(timeout=0.2)
129+
if not self._server_thread.is_alive():
130+
return {
131+
"success": False,
132+
"error": "Server failed to start (check port availability).",
133+
}
134+
142135
self._running = True
143136
await self._emit_status()
144-
145137
return {"success": True}
146138

147139
except Exception as exc:
@@ -169,7 +161,7 @@ async def stop_server(self) -> dict:
169161
async def get_status(self) -> dict:
170162
return {
171163
"running": self._running,
172-
"ip": _get_local_ip() if self._running else "",
164+
"ip": get_local_ip() if self._running else "",
173165
"port": self._get("port"),
174166
"root": self._get("root_dir"),
175167
}
@@ -185,19 +177,36 @@ async def save_settings(self, new_settings: dict) -> dict:
185177
try:
186178
assert self._settings is not None
187179

188-
port = int(new_settings.get("port", self.DEFAULTS["port"]))
189-
root = str(new_settings.get("root_dir", self.DEFAULTS["root_dir"]))
180+
port = int(new_settings.get("port", self._get("port")))
181+
root = str(new_settings.get("root_dir", self._get("root_dir")))
182+
p_start = int(
183+
new_settings.get("passive_port_start", self._get("passive_port_start"))
184+
)
185+
p_end = int(
186+
new_settings.get("passive_port_end", self._get("passive_port_end"))
187+
)
190188

191189
if not (1024 <= port <= 65535):
192190
return {"success": False, "error": "Port must be 1024–65535."}
193191
if not root.startswith("/"):
192+
return {"success": False, "error": "Root must be an absolute path."}
193+
if not (1024 <= p_start <= 65535 and 1024 <= p_end <= 65535):
194+
return {"success": False, "error": "Passive ports must be 1024–65535."}
195+
if p_end <= p_start:
196+
return {
197+
"success": False,
198+
"error": "Passive end must be greater than start.",
199+
}
200+
if p_start <= port <= p_end:
194201
return {
195202
"success": False,
196-
"error": "Root must be an absolute path.",
203+
"error": "Control port must not sit inside the passive range.",
197204
}
198205

199206
self._settings.setSetting("port", port)
200207
self._settings.setSetting("root_dir", root)
208+
self._settings.setSetting("passive_port_start", p_start)
209+
self._settings.setSetting("passive_port_end", p_end)
201210
self._settings.commit()
202211

203212
restarted = False
@@ -210,6 +219,8 @@ async def save_settings(self, new_settings: dict) -> dict:
210219
"error": f"Saved, but restart failed: {res.get('error')}",
211220
}
212221
restarted = True
222+
else:
223+
await self._emit_status()
213224

214225
return {"success": True, "restarted": restarted}
215226
except Exception as exc:

src/SettingsModal.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
import { ButtonItem, ModalRoot, TextField } from "@decky/ui";
22
import { callable, toaster } from "@decky/api";
33
import { useEffect, useState } from "react";
4-
5-
interface FtpdSettings {
6-
port: number;
7-
root_dir: string;
8-
}
9-
10-
const DEFAULTS: FtpdSettings = {
11-
port: 2121,
12-
root_dir: "/",
13-
};
4+
import { FtpdSettings } from "./types";
5+
import { DEFAULTS } from "./defaults";
146

157
const getSettings = callable<[], FtpdSettings>("get_settings");
168
const saveSettings = callable<

src/backend.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { callable } from "@decky/api";
2+
import { FtpdSettings, FtpdStatus, SaveResult, ToggleResult } from "./types";
3+
4+
export const startServer = callable<[], ToggleResult>("start_server");
5+
export const stopServer = callable<[], ToggleResult>("stop_server");
6+
export const getStatus = callable<[], FtpdStatus>("get_status");
7+
export const getSettings = callable<[], FtpdSettings>("get_settings");
8+
export const saveSettings = callable<
9+
[Record<string, string | number>],
10+
SaveResult
11+
>("save_settings");

src/defaults.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { FtpdSettings } from "./types";
2+
3+
export const DEFAULTS: FtpdSettings = {
4+
port: 2121,
5+
root_dir: "/",
6+
passive_port_start: 50000,
7+
passive_port_end: 50100,
8+
};
9+
10+
export const QUICK_PATHS: Array<{ label: string; path: string }> = [
11+
{ label: "Home", path: "/home/deck" },
12+
{ label: "SD Card", path: "/run/media" },
13+
{ label: "Everything", path: "/" },
14+
];

src/index.tsx

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,9 @@ import {
1717
import { useState, useEffect, useCallback } from "react";
1818
import { FaNetworkWired } from "react-icons/fa";
1919
import SettingsModal from "./SettingsModal";
20-
21-
interface FtpdStatus {
22-
running: boolean;
23-
ip: string;
24-
port: number;
25-
root: string;
26-
}
27-
28-
const getStatus = callable<[], FtpdStatus>("get_status");
29-
30-
const startServer = callable<[], { success: boolean; error?: string }>(
31-
"start_server",
32-
);
33-
const stopServer = callable<[], { success: boolean; error?: string }>(
34-
"stop_server",
35-
);
20+
import { FtpdStatus } from "./types";
21+
import { getStatus, startServer, stopServer } from "./backend";
22+
import { QUICK_PATHS } from "./defaults";
3623

3724
function StatusDot({ running }: { running: boolean }) {
3825
return (
@@ -76,12 +63,34 @@ function Content() {
7663
const [port, setPort] = useState<number>(21);
7764
const [root, setRoot] = useState<string>("/");
7865
const [toggling, setToggling] = useState<boolean>(false);
66+
const [savingPath, setSavingPath] = useState<string | null>(null);
67+
7968
const applyStatus = useCallback((s: FtpdStatus) => {
8069
setRunning(s.running);
8170
setIp(s.ip);
8271
setPort(s.port);
8372
setRoot(s.root);
8473
}, []);
74+
const saveSettings = callable<
75+
[Record<string, string | number>],
76+
{ success: boolean; error?: string; restarted?: boolean }
77+
>("save_settings");
78+
79+
const handleQuickPath = async (path: string) => {
80+
if (path === root || savingPath !== null) return;
81+
setSavingPath(path);
82+
try {
83+
const res = await saveSettings({ root_dir: path });
84+
if (!res.success) {
85+
toaster.toast({
86+
title: "decky-ftpd — error",
87+
body: res.error ?? "Failed to change path",
88+
});
89+
}
90+
} finally {
91+
setSavingPath(null);
92+
}
93+
};
8594

8695
useEffect(() => {
8796
let cancelled = false;
@@ -166,11 +175,34 @@ function Content() {
166175
)}
167176
</PanelSection>
168177

178+
<PanelSection title="Quick Paths">
179+
{QUICK_PATHS.map((qp) => {
180+
const active = root === qp.path;
181+
return (
182+
<PanelSectionRow key={qp.path}>
183+
<ButtonItem
184+
layout="below"
185+
disabled={savingPath !== null}
186+
description={
187+
<span style={{ fontFamily: "monospace", fontSize: 11 }}>
188+
{qp.path}
189+
</span>
190+
}
191+
onClick={() => handleQuickPath(qp.path)}
192+
>
193+
{active ? "✓ " : ""}
194+
{qp.label}
195+
</ButtonItem>
196+
</PanelSectionRow>
197+
);
198+
})}
199+
</PanelSection>
200+
169201
<PanelSection title="Options">
170202
<PanelSectionRow>
171203
<ButtonItem
172204
layout="below"
173-
description="Port, root directory, passive range…"
205+
description="Port, root directory, authentication"
174206
onClick={() => showModal(<SettingsModal />)}
175207
>
176208
Settings

src/types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export interface FtpdStatus {
2+
running: boolean;
3+
ip: string;
4+
port: number;
5+
root: string;
6+
}
7+
8+
export interface FtpdSettings {
9+
port: number;
10+
root_dir: string;
11+
passive_port_start: number;
12+
passive_port_end: number;
13+
}
14+
15+
export interface SaveResult {
16+
success: boolean;
17+
error?: string;
18+
restarted?: boolean;
19+
}
20+
21+
export interface ToggleResult {
22+
success: boolean;
23+
error?: string;
24+
already?: boolean;
25+
}

utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import os
2+
import socket
3+
import sys
4+
5+
PY_MODULES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "py_modules")
6+
7+
8+
def ensure_pyftpdlib() -> None:
9+
if PY_MODULES_DIR not in sys.path:
10+
sys.path.insert(0, PY_MODULES_DIR)
11+
12+
13+
def get_local_ip() -> str:
14+
try:
15+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
16+
s.connect(("8.8.8.8", 80))
17+
ip = s.getsockname()[0]
18+
s.close()
19+
return ip
20+
except Exception:
21+
return "Unknown"

0 commit comments

Comments
 (0)