From 364b5f4399ccf0861a6947b888c048ec3acb5c2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 13:40:03 +0000 Subject: [PATCH 1/5] feat: Add Remote Config System (MCED-Remote) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a two-component remote configuration editing system: **MCED-Remote (Java Server Agent)** - Standalone Java 17 JAR, platform-independent (no Fabric/Forge/Paper dependency) - Embedded HTTP server using Java's built-in com.sun.net.httpserver - REST API: GET/PUT/DELETE /api/v1/file, GET /api/v1/files, /status, /info - API key authentication via X-API-Key header - Path traversal protection via canonical path checking - File extension whitelist (.toml, .json, .json5, .yml, .yaml, .cfg, .properties) - Auto-generates mced-remote.properties with a UUID API key on first run - Maven build: mvn package → mced-remote.jar **MCED Client Integration** - RemoteConfigService.ts: Node.js fetch-based HTTP client with timeout/error handling - IPC handlers in index.ts: remote:connect, listFiles, readFile, writeFile, deleteFile, etc. - API keys stored encrypted via electron.safeStorage (fallback: plaintext) - Preload bridge extended with all remote: API methods - Shared TypeScript types in remote.types.ts https://claude.ai/code/session_01JK8L3K1rDjTudkw7Et6XTm --- mced-remote/pom.xml | 38 +++ .../main/java/com/mced/remote/MCEDRemote.java | 83 ++++++ .../com/mced/remote/config/AgentConfig.java | 87 +++++++ .../com/mced/remote/files/FileManager.java | 166 ++++++++++++ .../java/com/mced/remote/http/HttpServer.java | 48 ++++ .../java/com/mced/remote/http/Router.java | 53 ++++ .../remote/http/handlers/FileHandler.java | 129 ++++++++++ .../remote/http/handlers/FilesHandler.java | 90 +++++++ .../remote/http/handlers/InfoHandler.java | 84 +++++++ .../remote/http/handlers/StatusHandler.java | 52 ++++ .../mced/remote/security/AuthMiddleware.java | 49 ++++ .../mced/remote/security/PathSanitizer.java | 66 +++++ src/main/index.ts | 236 +++++++++++++++++- src/main/preload.ts | 32 +++ src/main/services/RemoteConfigService.ts | 138 ++++++++++ src/shared/types/remote.types.ts | 42 ++++ 16 files changed, 1392 insertions(+), 1 deletion(-) create mode 100644 mced-remote/pom.xml create mode 100644 mced-remote/src/main/java/com/mced/remote/MCEDRemote.java create mode 100644 mced-remote/src/main/java/com/mced/remote/config/AgentConfig.java create mode 100644 mced-remote/src/main/java/com/mced/remote/files/FileManager.java create mode 100644 mced-remote/src/main/java/com/mced/remote/http/HttpServer.java create mode 100644 mced-remote/src/main/java/com/mced/remote/http/Router.java create mode 100644 mced-remote/src/main/java/com/mced/remote/http/handlers/FileHandler.java create mode 100644 mced-remote/src/main/java/com/mced/remote/http/handlers/FilesHandler.java create mode 100644 mced-remote/src/main/java/com/mced/remote/http/handlers/InfoHandler.java create mode 100644 mced-remote/src/main/java/com/mced/remote/http/handlers/StatusHandler.java create mode 100644 mced-remote/src/main/java/com/mced/remote/security/AuthMiddleware.java create mode 100644 mced-remote/src/main/java/com/mced/remote/security/PathSanitizer.java create mode 100644 src/main/services/RemoteConfigService.ts create mode 100644 src/shared/types/remote.types.ts diff --git a/mced-remote/pom.xml b/mced-remote/pom.xml new file mode 100644 index 0000000..273c127 --- /dev/null +++ b/mced-remote/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + com.mced + mced-remote + 1.0.0 + jar + + MCED Remote Agent + Standalone remote config access agent for MCED (Minecraft Config Editor Desktop) + + + 17 + 17 + UTF-8 + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.mced.remote.MCEDRemote + + + mced-remote + + + + + diff --git a/mced-remote/src/main/java/com/mced/remote/MCEDRemote.java b/mced-remote/src/main/java/com/mced/remote/MCEDRemote.java new file mode 100644 index 0000000..b661622 --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/MCEDRemote.java @@ -0,0 +1,83 @@ +package com.mced.remote; + +import com.mced.remote.config.AgentConfig; +import com.mced.remote.files.FileManager; +import com.mced.remote.http.AgentHttpServer; +import com.mced.remote.security.PathSanitizer; + +import java.io.IOException; + +/** + * MCED Remote Agent - Entry Point + * + * A standalone platform-independent HTTP agent that exposes Minecraft server + * configuration files via a REST API to the MCED desktop application. + * + * Usage: java -jar mced-remote.jar + * + * On first run, creates mced-remote.properties with a generated API key. + */ +public class MCEDRemote { + + public static void main(String[] args) { + System.out.println("=============================================="); + System.out.println(" MCED Remote Agent v1.0.0"); + System.out.println(" Minecraft Config Editor - Remote Access"); + System.out.println("=============================================="); + + AgentConfig config; + try { + config = new AgentConfig(); + } catch (IOException e) { + System.err.println("[MCED-Remote] Failed to load configuration: " + e.getMessage()); + System.exit(1); + return; + } + + PathSanitizer sanitizer; + try { + sanitizer = new PathSanitizer(config); + } catch (IOException e) { + System.err.println("[MCED-Remote] Invalid root path: " + e.getMessage()); + System.exit(1); + return; + } + + System.out.println("[MCED-Remote] Root path: " + sanitizer.getRootPath()); + System.out.println("[MCED-Remote] API Key: " + config.getApiKey()); + + FileManager fileManager = new FileManager(config, sanitizer); + + AgentHttpServer httpServer; + try { + httpServer = new AgentHttpServer(config, sanitizer, fileManager); + } catch (IOException e) { + System.err.println("[MCED-Remote] Failed to start HTTP server on port " + config.getPort() + ": " + e.getMessage()); + System.exit(1); + return; + } + + httpServer.start(); + + // Graceful shutdown on SIGTERM / SIGINT + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("\n[MCED-Remote] Shutting down..."); + httpServer.stop(); + })); + + System.out.println("[MCED-Remote] Ready. Press Ctrl+C to stop."); + System.out.println("----------------------------------------------"); + System.out.println(" Connect from MCED Desktop:"); + System.out.println(" Host: "); + System.out.println(" Port: " + config.getPort()); + System.out.println(" API Key: " + config.getApiKey()); + System.out.println("----------------------------------------------"); + + // Keep alive + try { + Thread.currentThread().join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/config/AgentConfig.java b/mced-remote/src/main/java/com/mced/remote/config/AgentConfig.java new file mode 100644 index 0000000..f132940 --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/config/AgentConfig.java @@ -0,0 +1,87 @@ +package com.mced.remote.config; + +import java.io.*; +import java.nio.file.*; +import java.util.*; + +public class AgentConfig { + + private static final String CONFIG_FILE = "mced-remote.properties"; + private static final String DEFAULT_PORT = "25580"; + private static final String DEFAULT_ROOT_PATH = "./"; + private static final String DEFAULT_EXTENSIONS = ".toml,.json,.json5,.yml,.yaml,.cfg,.properties"; + private static final String DEFAULT_MAX_FILE_SIZE = "10485760"; + private static final String DEFAULT_SERVER_NAME = "My Minecraft Server"; + + private final Properties props; + + public AgentConfig() throws IOException { + this.props = new Properties(); + Path configPath = Paths.get(CONFIG_FILE); + + if (Files.exists(configPath)) { + try (InputStream in = Files.newInputStream(configPath)) { + props.load(in); + } + System.out.println("[MCED-Remote] Loaded config from " + configPath.toAbsolutePath()); + } else { + generateDefaults(); + save(); + System.out.println("[MCED-Remote] Created default config at " + configPath.toAbsolutePath()); + System.out.println("[MCED-Remote] Your API Key: " + props.getProperty("server.apiKey")); + } + } + + private void generateDefaults() { + props.setProperty("server.port", DEFAULT_PORT); + props.setProperty("server.apiKey", UUID.randomUUID().toString()); + props.setProperty("server.rootPath", DEFAULT_ROOT_PATH); + props.setProperty("server.allowedExtensions", DEFAULT_EXTENSIONS); + props.setProperty("server.maxFileSizeBytes", DEFAULT_MAX_FILE_SIZE); + props.setProperty("server.name", DEFAULT_SERVER_NAME); + } + + private void save() throws IOException { + try (OutputStream out = Files.newOutputStream(Paths.get(CONFIG_FILE))) { + props.store(out, "MCED Remote Agent Configuration\n" + + "# server.port - Port to listen on (default: 25580)\n" + + "# server.apiKey - Secret API key for authentication\n" + + "# server.rootPath - Root directory to expose (default: current directory)\n" + + "# server.allowedExtensions - Comma-separated list of allowed file extensions\n" + + "# server.maxFileSizeBytes - Maximum file size in bytes (default: 10MB)\n" + + "# server.name - Display name for this server"); + } + } + + public int getPort() { + return Integer.parseInt(props.getProperty("server.port", DEFAULT_PORT)); + } + + public String getApiKey() { + return props.getProperty("server.apiKey", ""); + } + + public String getRootPath() { + return props.getProperty("server.rootPath", DEFAULT_ROOT_PATH); + } + + public Set getAllowedExtensions() { + String[] parts = props.getProperty("server.allowedExtensions", DEFAULT_EXTENSIONS).split(","); + Set exts = new HashSet<>(); + for (String part : parts) { + String trimmed = part.trim().toLowerCase(); + if (!trimmed.isEmpty()) { + exts.add(trimmed); + } + } + return exts; + } + + public long getMaxFileSizeBytes() { + return Long.parseLong(props.getProperty("server.maxFileSizeBytes", DEFAULT_MAX_FILE_SIZE)); + } + + public String getServerName() { + return props.getProperty("server.name", DEFAULT_SERVER_NAME); + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/files/FileManager.java b/mced-remote/src/main/java/com/mced/remote/files/FileManager.java new file mode 100644 index 0000000..7bbe58a --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/files/FileManager.java @@ -0,0 +1,166 @@ +package com.mced.remote.files; + +import com.mced.remote.config.AgentConfig; +import com.mced.remote.security.PathSanitizer; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; + +public class FileManager { + + private final PathSanitizer sanitizer; + private final long maxFileSizeBytes; + + public FileManager(AgentConfig config, PathSanitizer sanitizer) { + this.sanitizer = sanitizer; + this.maxFileSizeBytes = config.getMaxFileSizeBytes(); + } + + public record FileEntry(String path, long size, long lastModified, boolean isDirectory) {} + + /** + * Lists files in the given path (relative to root). + * @param relativePath relative path (null or "" for root) + * @param recursive if true, recurse into subdirectories + */ + public List listFiles(String relativePath, boolean recursive) throws IOException { + Path dir = sanitizer.resolveSafe(relativePath); + + if (!Files.exists(dir)) { + throw new FileNotFoundException("Directory not found: " + relativePath); + } + if (!Files.isDirectory(dir)) { + throw new IOException("Path is not a directory: " + relativePath); + } + + List result = new ArrayList<>(); + + if (recursive) { + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + String ext = getExtension(file.getFileName().toString()); + // Only include allowed extensions in recursive listing + try { + sanitizer.resolveSafe(sanitizer.relativize(file)); // validates extension + result.add(new FileEntry( + sanitizer.relativize(file), + attrs.size(), + attrs.lastModifiedTime().toMillis(), + false + )); + } catch (SecurityException | IOException ignored) { + // Skip disallowed files silently + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs) { + if (!directory.equals(dir)) { + result.add(new FileEntry( + sanitizer.relativize(directory), + 0, + attrs.lastModifiedTime().toMillis(), + true + )); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + return FileVisitResult.CONTINUE; + } + }); + } else { + try (DirectoryStream stream = Files.newDirectoryStream(dir)) { + for (Path entry : stream) { + BasicFileAttributes attrs = Files.readAttributes(entry, BasicFileAttributes.class); + boolean isDir = attrs.isDirectory(); + + if (!isDir) { + try { + sanitizer.resolveSafe(sanitizer.relativize(entry)); // validates extension + } catch (SecurityException ignored) { + continue; // Skip disallowed extensions + } + } + + result.add(new FileEntry( + sanitizer.relativize(entry), + isDir ? 0 : attrs.size(), + attrs.lastModifiedTime().toMillis(), + isDir + )); + } + } + } + + result.sort(Comparator + .comparing((FileEntry e) -> !e.isDirectory()) // directories first + .thenComparing(FileEntry::path)); + + return result; + } + + /** + * Reads file content as UTF-8 string. + */ + public String readFile(String relativePath) throws IOException { + Path file = sanitizer.resolveSafe(relativePath); + + if (!Files.exists(file)) { + throw new FileNotFoundException("File not found: " + relativePath); + } + if (Files.isDirectory(file)) { + throw new IOException("Path is a directory: " + relativePath); + } + + long size = Files.size(file); + if (size > maxFileSizeBytes) { + throw new IOException("File too large: " + size + " bytes (max: " + maxFileSizeBytes + ")"); + } + + return Files.readString(file, StandardCharsets.UTF_8); + } + + /** + * Writes content to a file (creates parent directories if needed). + */ + public void writeFile(String relativePath, String content) throws IOException { + Path file = sanitizer.resolveSafe(relativePath); + + // Create parent directories if needed + Path parent = file.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + + Files.writeString(file, content, StandardCharsets.UTF_8); + } + + /** + * Deletes a file (not directories). + */ + public void deleteFile(String relativePath) throws IOException { + Path file = sanitizer.resolveSafe(relativePath); + + if (!Files.exists(file)) { + throw new FileNotFoundException("File not found: " + relativePath); + } + if (Files.isDirectory(file)) { + throw new IOException("Cannot delete directories: " + relativePath); + } + + Files.delete(file); + } + + private String getExtension(String filename) { + int dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.substring(dot).toLowerCase() : ""; + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/http/HttpServer.java b/mced-remote/src/main/java/com/mced/remote/http/HttpServer.java new file mode 100644 index 0000000..c42d15d --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/http/HttpServer.java @@ -0,0 +1,48 @@ +package com.mced.remote.http; + +import com.mced.remote.config.AgentConfig; +import com.mced.remote.files.FileManager; +import com.mced.remote.http.handlers.*; +import com.mced.remote.security.AuthMiddleware; +import com.mced.remote.security.PathSanitizer; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.concurrent.Executors; + +public class AgentHttpServer { + + private final HttpServer server; + private final int port; + + public AgentHttpServer(AgentConfig config, PathSanitizer sanitizer, FileManager fileManager) throws IOException { + this.port = config.getPort(); + + AuthMiddleware auth = new AuthMiddleware(config); + + // Create server + server = HttpServer.create(new InetSocketAddress(port), 0); + server.setExecutor(Executors.newFixedThreadPool(4)); + + // Build router + Router router = new Router(); + router.addRoute("/api/v1/status", new StatusHandler(config)); + router.addRoute("/api/v1/files", new FilesHandler(auth, fileManager)); + router.addRoute("/api/v1/file", new FileHandler(auth, fileManager)); + router.addRoute("/api/v1/info", new InfoHandler(auth, config, sanitizer)); + + server.createContext("/", router); + } + + public void start() { + server.start(); + System.out.println("[MCED-Remote] Server running on port " + port); + System.out.println("[MCED-Remote] Status: http://localhost:" + port + "/api/v1/status"); + } + + public void stop() { + server.stop(1); + System.out.println("[MCED-Remote] Server stopped."); + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/http/Router.java b/mced-remote/src/main/java/com/mced/remote/http/Router.java new file mode 100644 index 0000000..d45f4e6 --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/http/Router.java @@ -0,0 +1,53 @@ +package com.mced.remote.http; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +import static com.mced.remote.http.handlers.StatusHandler.sendJson; +import static com.mced.remote.security.AuthMiddleware.sendError; + +/** + * Simple path-based router for the embedded HttpServer. + */ +public class Router implements HttpHandler { + + private final Map routes = new LinkedHashMap<>(); + + public void addRoute(String path, HttpHandler handler) { + routes.put(path, handler); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + String requestPath = exchange.getRequestURI().getPath(); + + // Find matching route (exact or prefix match) + HttpHandler handler = routes.get(requestPath); + if (handler == null) { + // Try prefix match + for (Map.Entry entry : routes.entrySet()) { + if (requestPath.startsWith(entry.getKey())) { + handler = entry.getValue(); + break; + } + } + } + + if (handler != null) { + try { + handler.handle(exchange); + } catch (Exception e) { + System.err.println("[MCED-Remote] Handler error: " + e.getMessage()); + try { + sendError(exchange, 500, "INTERNAL_ERROR", "Unexpected server error"); + } catch (Exception ignored) {} + } + } else { + sendError(exchange, 404, "NOT_FOUND", "No route found for: " + requestPath); + } + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/http/handlers/FileHandler.java b/mced-remote/src/main/java/com/mced/remote/http/handlers/FileHandler.java new file mode 100644 index 0000000..54f11f3 --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/http/handlers/FileHandler.java @@ -0,0 +1,129 @@ +package com.mced.remote.http.handlers; + +import com.mced.remote.files.FileManager; +import com.mced.remote.security.AuthMiddleware; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import java.io.*; +import java.nio.charset.StandardCharsets; + +import static com.mced.remote.http.handlers.StatusHandler.escape; +import static com.mced.remote.http.handlers.StatusHandler.sendJson; + +public class FileHandler implements HttpHandler { + + private final AuthMiddleware auth; + private final FileManager fileManager; + + public FileHandler(AuthMiddleware auth, FileManager fileManager) { + this.auth = auth; + this.fileManager = fileManager; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + // Handle CORS preflight + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { + sendJson(exchange, 204, ""); + return; + } + + if (!auth.authenticate(exchange)) return; + + String method = exchange.getRequestMethod().toUpperCase(); + String query = exchange.getRequestURI().getQuery(); + String path = getQueryParam(query, "path"); + + if (path == null || path.isEmpty()) { + AuthMiddleware.sendError(exchange, 400, "BAD_REQUEST", "Missing 'path' query parameter"); + return; + } + + try { + switch (method) { + case "GET" -> handleRead(exchange, path); + case "PUT" -> handleWrite(exchange, path); + case "DELETE" -> handleDelete(exchange, path); + default -> sendJson(exchange, 405, "{\"error\":\"METHOD_NOT_ALLOWED\",\"message\":\"Use GET, PUT, or DELETE\"}"); + } + } catch (SecurityException e) { + AuthMiddleware.sendError(exchange, 403, "FORBIDDEN", e.getMessage()); + } catch (FileNotFoundException e) { + AuthMiddleware.sendError(exchange, 404, "NOT_FOUND", e.getMessage()); + } catch (IOException e) { + AuthMiddleware.sendError(exchange, 500, "INTERNAL_ERROR", e.getMessage()); + } + } + + private void handleRead(HttpExchange exchange, String path) throws IOException { + String content = fileManager.readFile(path); + String body = "{" + + "\"path\":\"" + escape(path) + "\"," + + "\"content\":" + toJsonString(content) + "," + + "\"encoding\":\"UTF-8\"" + + "}"; + sendJson(exchange, 200, body); + } + + private void handleWrite(HttpExchange exchange, String path) throws IOException { + // Read request body + String content; + try (InputStream is = exchange.getRequestBody()) { + content = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + + fileManager.writeFile(path, content); + sendJson(exchange, 200, "{\"success\":true,\"path\":\"" + escape(path) + "\"}"); + } + + private void handleDelete(HttpExchange exchange, String path) throws IOException { + fileManager.deleteFile(path); + sendJson(exchange, 200, "{\"success\":true,\"path\":\"" + escape(path) + "\"}"); + } + + /** + * Converts a Java string to a valid JSON string literal (with surrounding quotes). + */ + private String toJsonString(String s) { + if (s == null) return "null"; + StringBuilder sb = new StringBuilder("\""); + for (char c : s.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + sb.append("\""); + return sb.toString(); + } + + private String getQueryParam(String query, String name) { + if (query == null) return null; + for (String part : query.split("&")) { + int eq = part.indexOf('='); + if (eq > 0) { + String key = part.substring(0, eq); + String value = part.substring(eq + 1); + if (key.equals(name)) { + try { + return java.net.URLDecoder.decode(value, "UTF-8"); + } catch (Exception e) { + return value; + } + } + } + } + return null; + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/http/handlers/FilesHandler.java b/mced-remote/src/main/java/com/mced/remote/http/handlers/FilesHandler.java new file mode 100644 index 0000000..15b4b1a --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/http/handlers/FilesHandler.java @@ -0,0 +1,90 @@ +package com.mced.remote.http.handlers; + +import com.mced.remote.files.FileManager; +import com.mced.remote.security.AuthMiddleware; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import java.io.IOException; +import java.net.URI; +import java.util.List; + +import static com.mced.remote.http.handlers.StatusHandler.escape; +import static com.mced.remote.http.handlers.StatusHandler.sendJson; + +public class FilesHandler implements HttpHandler { + + private final AuthMiddleware auth; + private final FileManager fileManager; + + public FilesHandler(AuthMiddleware auth, FileManager fileManager) { + this.auth = auth; + this.fileManager = fileManager; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + // Handle CORS preflight + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { + sendJson(exchange, 204, ""); + return; + } + + if (!auth.authenticate(exchange)) return; + + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + sendJson(exchange, 405, "{\"error\":\"METHOD_NOT_ALLOWED\",\"message\":\"Only GET is allowed\"}"); + return; + } + + // Parse query params + String query = exchange.getRequestURI().getQuery(); + String path = getQueryParam(query, "path"); + boolean recursive = "true".equalsIgnoreCase(getQueryParam(query, "recursive")); + + try { + List files = fileManager.listFiles(path, recursive); + + StringBuilder sb = new StringBuilder("{\"files\":["); + for (int i = 0; i < files.size(); i++) { + FileManager.FileEntry f = files.get(i); + if (i > 0) sb.append(","); + sb.append("{") + .append("\"path\":\"").append(escape(f.path())).append("\",") + .append("\"size\":").append(f.size()).append(",") + .append("\"lastModified\":").append(f.lastModified()).append(",") + .append("\"isDirectory\":").append(f.isDirectory()) + .append("}"); + } + sb.append("]}"); + + sendJson(exchange, 200, sb.toString()); + + } catch (SecurityException e) { + AuthMiddleware.sendError(exchange, 403, "FORBIDDEN", e.getMessage()); + } catch (java.io.FileNotFoundException e) { + AuthMiddleware.sendError(exchange, 404, "NOT_FOUND", e.getMessage()); + } catch (IOException e) { + AuthMiddleware.sendError(exchange, 500, "INTERNAL_ERROR", e.getMessage()); + } + } + + private String getQueryParam(String query, String name) { + if (query == null) return null; + for (String part : query.split("&")) { + int eq = part.indexOf('='); + if (eq > 0) { + String key = part.substring(0, eq); + String value = part.substring(eq + 1); + if (key.equals(name)) { + try { + return java.net.URLDecoder.decode(value, "UTF-8"); + } catch (Exception e) { + return value; + } + } + } + } + return null; + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/http/handlers/InfoHandler.java b/mced-remote/src/main/java/com/mced/remote/http/handlers/InfoHandler.java new file mode 100644 index 0000000..2b1100d --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/http/handlers/InfoHandler.java @@ -0,0 +1,84 @@ +package com.mced.remote.http.handlers; + +import com.mced.remote.config.AgentConfig; +import com.mced.remote.security.AuthMiddleware; +import com.mced.remote.security.PathSanitizer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import java.io.IOException; +import java.nio.file.*; + +import static com.mced.remote.http.handlers.StatusHandler.escape; +import static com.mced.remote.http.handlers.StatusHandler.sendJson; + +public class InfoHandler implements HttpHandler { + + private final AuthMiddleware auth; + private final AgentConfig config; + private final PathSanitizer sanitizer; + + public InfoHandler(AuthMiddleware auth, AgentConfig config, PathSanitizer sanitizer) { + this.auth = auth; + this.config = config; + this.sanitizer = sanitizer; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { + sendJson(exchange, 204, ""); + return; + } + + if (!auth.authenticate(exchange)) return; + + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + sendJson(exchange, 405, "{\"error\":\"METHOD_NOT_ALLOWED\",\"message\":\"Only GET is allowed\"}"); + return; + } + + Path root = sanitizer.getRootPath(); + String javaVersion = System.getProperty("java.version", "unknown"); + String os = System.getProperty("os.name", "unknown"); + + // Detect common Minecraft server directories + boolean hasConfigDir = Files.isDirectory(root.resolve("config")); + boolean hasMods = Files.isDirectory(root.resolve("mods")); + boolean hasPlugins = Files.isDirectory(root.resolve("plugins")); + boolean hasServerProps = Files.exists(root.resolve("server.properties")); + + String serverType = "unknown"; + if (hasMods && hasConfigDir) serverType = "modded"; + else if (hasPlugins) serverType = "plugin"; + else if (hasServerProps) serverType = "vanilla"; + + // Detect mod loader + String modLoader = "unknown"; + if (Files.exists(root.resolve("fabric-server-launch.jar")) || Files.exists(root.resolve("fabric-server.jar"))) { + modLoader = "fabric"; + } else if (Files.exists(root.resolve("forge")) || Files.isDirectory(root.resolve("libraries/net/minecraftforge"))) { + modLoader = "forge"; + } else if (Files.isDirectory(root.resolve("libraries/net/neoforged"))) { + modLoader = "neoforge"; + } else if (Files.exists(root.resolve("paper.jar")) || Files.exists(root.resolve("purpur.jar"))) { + modLoader = "paper"; + } else if (Files.exists(root.resolve("spigot.jar"))) { + modLoader = "spigot"; + } + + String body = "{" + + "\"serverName\":\"" + escape(config.getServerName()) + "\"," + + "\"rootPath\":\"" + escape(root.toString()) + "\"," + + "\"serverType\":\"" + serverType + "\"," + + "\"modLoader\":\"" + modLoader + "\"," + + "\"hasConfigDir\":" + hasConfigDir + "," + + "\"hasMods\":" + hasMods + "," + + "\"hasPlugins\":" + hasPlugins + "," + + "\"javaVersion\":\"" + escape(javaVersion) + "\"," + + "\"os\":\"" + escape(os) + "\"" + + "}"; + + sendJson(exchange, 200, body); + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/http/handlers/StatusHandler.java b/mced-remote/src/main/java/com/mced/remote/http/handlers/StatusHandler.java new file mode 100644 index 0000000..266933b --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/http/handlers/StatusHandler.java @@ -0,0 +1,52 @@ +package com.mced.remote.http.handlers; + +import com.mced.remote.config.AgentConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +public class StatusHandler implements HttpHandler { + + private static final String VERSION = "1.0.0"; + private final AgentConfig config; + + public StatusHandler(AgentConfig config) { + this.config = config; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + sendJson(exchange, 405, "{\"error\":\"METHOD_NOT_ALLOWED\",\"message\":\"Only GET is allowed\"}"); + return; + } + + String body = "{" + + "\"status\":\"ok\"," + + "\"version\":\"" + VERSION + "\"," + + "\"serverName\":\"" + escape(config.getServerName()) + "\"" + + "}"; + + sendJson(exchange, 200, body); + } + + static void sendJson(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "X-API-Key, Content-Type"); + exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + static String escape(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/security/AuthMiddleware.java b/mced-remote/src/main/java/com/mced/remote/security/AuthMiddleware.java new file mode 100644 index 0000000..e16dc08 --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/security/AuthMiddleware.java @@ -0,0 +1,49 @@ +package com.mced.remote.security; + +import com.mced.remote.config.AgentConfig; +import com.sun.net.httpserver.HttpExchange; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +public class AuthMiddleware { + + private final String apiKey; + + public AuthMiddleware(AgentConfig config) { + this.apiKey = config.getApiKey(); + } + + /** + * Returns true if the request has a valid API key. + * If false, it also writes a 401 response automatically. + */ + public boolean authenticate(HttpExchange exchange) throws IOException { + String header = exchange.getRequestHeaders().getFirst("X-API-Key"); + if (header == null || header.isEmpty()) { + sendError(exchange, 401, "UNAUTHORIZED", "Missing X-API-Key header"); + return false; + } + if (!header.equals(apiKey)) { + sendError(exchange, 401, "UNAUTHORIZED", "Invalid API key"); + return false; + } + return true; + } + + public static void sendError(HttpExchange exchange, int statusCode, String error, String message) throws IOException { + String body = "{\"error\":\"" + escape(error) + "\",\"message\":\"" + escape(message) + "\"}"; + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + private static String escape(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } +} diff --git a/mced-remote/src/main/java/com/mced/remote/security/PathSanitizer.java b/mced-remote/src/main/java/com/mced/remote/security/PathSanitizer.java new file mode 100644 index 0000000..0b1da1e --- /dev/null +++ b/mced-remote/src/main/java/com/mced/remote/security/PathSanitizer.java @@ -0,0 +1,66 @@ +package com.mced.remote.security; + +import com.mced.remote.config.AgentConfig; + +import java.io.IOException; +import java.nio.file.*; +import java.util.Set; + +public class PathSanitizer { + + private final Path rootPath; + private final Set allowedExtensions; + + public PathSanitizer(AgentConfig config) throws IOException { + this.rootPath = Paths.get(config.getRootPath()).toRealPath(); + this.allowedExtensions = config.getAllowedExtensions(); + } + + /** + * Resolves and validates a user-supplied relative path against the root. + * Throws SecurityException on path traversal or disallowed extensions. + */ + public Path resolveSafe(String userPath) throws IOException, SecurityException { + if (userPath == null || userPath.isEmpty()) { + return rootPath; + } + + // Normalize separators and strip leading slashes + String normalized = userPath.replace('\\', '/').replaceAll("^/+", ""); + + Path resolved = rootPath.resolve(normalized).normalize(); + + // Check that resolved path is inside rootPath (prevent traversal) + if (!resolved.startsWith(rootPath)) { + throw new SecurityException("Path traversal attempt blocked: " + userPath); + } + + // Check extension (only for files, not directories) + if (!normalized.isEmpty() && !normalized.endsWith("/")) { + String filename = resolved.getFileName() != null ? resolved.getFileName().toString() : ""; + int dotIndex = filename.lastIndexOf('.'); + if (dotIndex >= 0) { + String ext = filename.substring(dotIndex).toLowerCase(); + if (!allowedExtensions.contains(ext)) { + throw new SecurityException("File extension not allowed: " + ext); + } + } else if (Files.exists(resolved) && !Files.isDirectory(resolved)) { + // Existing files without extension are not allowed + throw new SecurityException("Files without extension are not allowed: " + userPath); + } + } + + return resolved; + } + + /** + * Returns the relative path string from rootPath to the given absolute path. + */ + public String relativize(Path absolute) { + return rootPath.relativize(absolute).toString().replace('\\', '/'); + } + + public Path getRootPath() { + return rootPath; + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 00419d5..d68333c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, dialog, shell } from "electron"; +import { app, BrowserWindow, ipcMain, dialog, shell, safeStorage } from "electron"; import path from "path"; import { promises as fs } from "fs"; import { exec } from "child_process"; @@ -12,6 +12,8 @@ import { ItemRegistryService } from "./services/ItemRegistryService"; import { FluidRegistryService } from "./services/FluidRegistryService"; import { RecipeService } from "./services/RecipeService"; import { JarLoaderService } from "./services/JarLoaderService"; +import { RemoteConfigService } from "./services/RemoteConfigService"; +import { RemoteConnection } from "../shared/types/remote.types"; // Game launchers removed due to Java compatibility issues // TODO: Re-implement in future when stable solution is found import "../shared/config"; // Import to silence console.logs in production @@ -1865,3 +1867,235 @@ ipcMain.handle("kubejs:validateScript", async (_event, code: string) => { return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); + +// ============================================================================= +// Remote Config System (MCED-Remote) +// ============================================================================= + +const REMOTE_CONNECTIONS_FILE = "remote-connections.json"; + +function getRemoteConnectionsPath(): string { + return path.join(app.getPath("userData"), REMOTE_CONNECTIONS_FILE); +} + +interface StoredConnection { + id: string; + name: string; + host: string; + port: number; + encryptedApiKey: string | null; // base64-encoded encrypted key, or null if safeStorage unavailable + apiKeyPlain: string | null; // only set when safeStorage is unavailable + lastConnected?: number; +} + +async function loadStoredConnections(): Promise { + try { + const filePath = getRemoteConnectionsPath(); + const content = await fs.readFile(filePath, "utf-8"); + return JSON.parse(content) as StoredConnection[]; + } catch { + return []; + } +} + +async function saveStoredConnections(connections: StoredConnection[]): Promise { + const filePath = getRemoteConnectionsPath(); + await fs.writeFile(filePath, JSON.stringify(connections, null, 2), "utf-8"); +} + +function encryptApiKey(apiKey: string): { encrypted: string | null; plain: string | null } { + if (safeStorage.isEncryptionAvailable()) { + const buf = safeStorage.encryptString(apiKey); + return { encrypted: buf.toString("base64"), plain: null }; + } + return { encrypted: null, plain: apiKey }; +} + +function decryptApiKey(stored: StoredConnection): string { + if (stored.encryptedApiKey && safeStorage.isEncryptionAvailable()) { + const buf = Buffer.from(stored.encryptedApiKey, "base64"); + return safeStorage.decryptString(buf); + } + return stored.apiKeyPlain ?? ""; +} + +function storedToConnection(stored: StoredConnection): RemoteConnection { + return { + id: stored.id, + name: stored.name, + host: stored.host, + port: stored.port, + apiKey: decryptApiKey(stored), + lastConnected: stored.lastConnected, + }; +} + +// Active RemoteConfigService instance (one at a time) +let activeRemoteService: RemoteConfigService | null = null; +let activeConnectionId: string | null = null; + +ipcMain.handle("remote:getSavedConnections", async () => { + try { + const stored = await loadStoredConnections(); + return { + success: true, + data: stored.map((s) => ({ + id: s.id, + name: s.name, + host: s.host, + port: s.port, + apiKey: "***", // Never expose API key to renderer + lastConnected: s.lastConnected, + })), + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); + +ipcMain.handle("remote:saveConnection", async (_event, connection: RemoteConnection) => { + try { + const stored = await loadStoredConnections(); + const { encrypted, plain } = encryptApiKey(connection.apiKey); + const newEntry: StoredConnection = { + id: connection.id, + name: connection.name, + host: connection.host, + port: connection.port, + encryptedApiKey: encrypted, + apiKeyPlain: plain, + lastConnected: connection.lastConnected, + }; + const existing = stored.findIndex((s) => s.id === connection.id); + if (existing >= 0) { + stored[existing] = newEntry; + } else { + stored.push(newEntry); + } + await saveStoredConnections(stored); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); + +ipcMain.handle("remote:deleteConnection", async (_event, id: string) => { + try { + const stored = await loadStoredConnections(); + const filtered = stored.filter((s) => s.id !== id); + await saveStoredConnections(filtered); + if (activeConnectionId === id) { + activeRemoteService = null; + activeConnectionId = null; + } + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); + +ipcMain.handle("remote:testConnection", async (_event, connection: RemoteConnection) => { + try { + const service = new RemoteConfigService(connection); + const result = await service.testConnection(); + return { success: result.success, data: result.status, error: result.error }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); + +ipcMain.handle("remote:connect", async (_event, connectionId: string) => { + try { + const stored = await loadStoredConnections(); + const storedConn = stored.find((s) => s.id === connectionId); + if (!storedConn) { + return { success: false, error: "Connection not found" }; + } + const connection = storedToConnection(storedConn); + const service = new RemoteConfigService(connection); + const result = await service.testConnection(); + if (!result.success) { + return { success: false, error: result.error }; + } + activeRemoteService = service; + activeConnectionId = connectionId; + + // Update lastConnected + storedConn.lastConnected = Date.now(); + await saveStoredConnections(stored); + + return { success: true, data: result.status }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); + +ipcMain.handle("remote:disconnect", async () => { + activeRemoteService = null; + activeConnectionId = null; + return { success: true }; +}); + +ipcMain.handle("remote:getActiveConnectionId", async () => { + return { success: true, data: activeConnectionId }; +}); + +ipcMain.handle("remote:getInfo", async () => { + if (!activeRemoteService) { + return { success: false, error: "Not connected to a remote server" }; + } + try { + const info = await activeRemoteService.getInfo(); + return { success: true, data: info }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); + +ipcMain.handle("remote:listFiles", async (_event, filePath?: string, recursive?: boolean) => { + if (!activeRemoteService) { + return { success: false, error: "Not connected to a remote server" }; + } + try { + const files = await activeRemoteService.listFiles(filePath, recursive ?? false); + return { success: true, data: files }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); + +ipcMain.handle("remote:readFile", async (_event, filePath: string) => { + if (!activeRemoteService) { + return { success: false, error: "Not connected to a remote server" }; + } + try { + const content = await activeRemoteService.readFile(filePath); + return { success: true, data: content }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); + +ipcMain.handle("remote:writeFile", async (_event, filePath: string, content: string) => { + if (!activeRemoteService) { + return { success: false, error: "Not connected to a remote server" }; + } + try { + await activeRemoteService.writeFile(filePath, content); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); + +ipcMain.handle("remote:deleteFile", async (_event, filePath: string) => { + if (!activeRemoteService) { + return { success: false, error: "Not connected to a remote server" }; + } + try { + await activeRemoteService.deleteFile(filePath); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); diff --git a/src/main/preload.ts b/src/main/preload.ts index f074eef..75e3bf4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -174,6 +174,24 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("recipe:delete", instancePath, scriptPath, recipeId), recipeSearch: (instancePath: string, query: string) => ipcRenderer.invoke("recipe:search", instancePath, query), + + // Remote Config System (MCED-Remote) + remoteGetSavedConnections: () => ipcRenderer.invoke("remote:getSavedConnections"), + remoteSaveConnection: (connection: import("../shared/types/remote.types").RemoteConnection) => + ipcRenderer.invoke("remote:saveConnection", connection), + remoteDeleteConnection: (id: string) => ipcRenderer.invoke("remote:deleteConnection", id), + remoteTestConnection: (connection: import("../shared/types/remote.types").RemoteConnection) => + ipcRenderer.invoke("remote:testConnection", connection), + remoteConnect: (connectionId: string) => ipcRenderer.invoke("remote:connect", connectionId), + remoteDisconnect: () => ipcRenderer.invoke("remote:disconnect"), + remoteGetActiveConnectionId: () => ipcRenderer.invoke("remote:getActiveConnectionId"), + remoteGetInfo: () => ipcRenderer.invoke("remote:getInfo"), + remoteListFiles: (filePath?: string, recursive?: boolean) => + ipcRenderer.invoke("remote:listFiles", filePath, recursive), + remoteReadFile: (filePath: string) => ipcRenderer.invoke("remote:readFile", filePath), + remoteWriteFile: (filePath: string, content: string) => + ipcRenderer.invoke("remote:writeFile", filePath, content), + remoteDeleteFile: (filePath: string) => ipcRenderer.invoke("remote:deleteFile", filePath), }); declare global { @@ -439,6 +457,20 @@ declare global { instancePath: string, query: string ) => Promise<{ success: boolean; data: any[]; error?: string }>; + + // Remote Config System (MCED-Remote) + remoteGetSavedConnections: () => Promise<{ success: boolean; data?: any[]; error?: string }>; + remoteSaveConnection: (connection: any) => Promise<{ success: boolean; error?: string }>; + remoteDeleteConnection: (id: string) => Promise<{ success: boolean; error?: string }>; + remoteTestConnection: (connection: any) => Promise<{ success: boolean; data?: any; error?: string }>; + remoteConnect: (connectionId: string) => Promise<{ success: boolean; data?: any; error?: string }>; + remoteDisconnect: () => Promise<{ success: boolean; error?: string }>; + remoteGetActiveConnectionId: () => Promise<{ success: boolean; data?: string | null; error?: string }>; + remoteGetInfo: () => Promise<{ success: boolean; data?: any; error?: string }>; + remoteListFiles: (path?: string, recursive?: boolean) => Promise<{ success: boolean; data?: any[]; error?: string }>; + remoteReadFile: (path: string) => Promise<{ success: boolean; data?: string; error?: string }>; + remoteWriteFile: (path: string, content: string) => Promise<{ success: boolean; error?: string }>; + remoteDeleteFile: (path: string) => Promise<{ success: boolean; error?: string }>; }; } } diff --git a/src/main/services/RemoteConfigService.ts b/src/main/services/RemoteConfigService.ts new file mode 100644 index 0000000..0650728 --- /dev/null +++ b/src/main/services/RemoteConfigService.ts @@ -0,0 +1,138 @@ +import { + RemoteConnection, + RemoteFile, + RemoteStatus, + RemoteServerInfo, + RemoteErrorCode, +} from "../../shared/types/remote.types"; + +const REQUEST_TIMEOUT_MS = 10_000; + +function buildBaseUrl(conn: RemoteConnection): string { + return `http://${conn.host}:${conn.port}/api/v1`; +} + +async function fetchWithTimeout( + url: string, + options: RequestInit = {} +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + throw makeError("TIMEOUT", "Request timed out after 10 seconds"); + } + const message = err instanceof Error ? err.message : String(err); + if (message.includes("ECONNREFUSED") || message.includes("ENOTFOUND") || message.includes("fetch failed")) { + throw makeError("CONNECTION_REFUSED", `Cannot connect to ${url}: ${message}`); + } + throw makeError("UNKNOWN", message); + } finally { + clearTimeout(timeoutId); + } +} + +function makeError(code: RemoteErrorCode["code"], message: string): Error & { remoteCode: string } { + const err = new Error(message) as Error & { remoteCode: string }; + err.remoteCode = code; + return err; +} + +async function parseResponse(response: Response): Promise { + const text = await response.text(); + let json: Record; + try { + json = JSON.parse(text) as Record; + } catch { + throw makeError("INTERNAL_ERROR", `Invalid JSON response: ${text.slice(0, 200)}`); + } + + if (!response.ok) { + const errorCode = (json.error as string) || "UNKNOWN"; + const message = (json.message as string) || `HTTP ${response.status}`; + if (response.status === 401) throw makeError("AUTH_FAILED", message); + if (response.status === 403) throw makeError("FORBIDDEN", message); + if (response.status === 404) throw makeError("NOT_FOUND", message); + throw makeError(errorCode as RemoteErrorCode["code"], message); + } + + return json as T; +} + +export class RemoteConfigService { + private connection: RemoteConnection; + + constructor(connection: RemoteConnection) { + this.connection = connection; + } + + private headers(): HeadersInit { + return { + "X-API-Key": this.connection.apiKey, + "Content-Type": "text/plain; charset=UTF-8", + }; + } + + async getStatus(): Promise { + const url = `${buildBaseUrl(this.connection)}/status`; + const response = await fetchWithTimeout(url); + return parseResponse(response); + } + + async testConnection(): Promise<{ success: boolean; status?: RemoteStatus; error?: string }> { + try { + const status = await this.getStatus(); + return { success: true, status }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: message }; + } + } + + async getInfo(): Promise { + const url = `${buildBaseUrl(this.connection)}/info`; + const response = await fetchWithTimeout(url, { headers: this.headers() }); + return parseResponse(response); + } + + async listFiles(path?: string, recursive = false): Promise { + let url = `${buildBaseUrl(this.connection)}/files`; + const params = new URLSearchParams(); + if (path) params.set("path", path); + if (recursive) params.set("recursive", "true"); + const query = params.toString(); + if (query) url += "?" + query; + + const response = await fetchWithTimeout(url, { headers: this.headers() }); + const data = await parseResponse<{ files: RemoteFile[] }>(response); + return data.files; + } + + async readFile(path: string): Promise { + const url = `${buildBaseUrl(this.connection)}/file?path=${encodeURIComponent(path)}`; + const response = await fetchWithTimeout(url, { headers: this.headers() }); + const data = await parseResponse<{ content: string }>(response); + return data.content; + } + + async writeFile(path: string, content: string): Promise { + const url = `${buildBaseUrl(this.connection)}/file?path=${encodeURIComponent(path)}`; + const response = await fetchWithTimeout(url, { + method: "PUT", + headers: this.headers(), + body: content, + }); + await parseResponse(response); + } + + async deleteFile(path: string): Promise { + const url = `${buildBaseUrl(this.connection)}/file?path=${encodeURIComponent(path)}`; + const response = await fetchWithTimeout(url, { + method: "DELETE", + headers: this.headers(), + }); + await parseResponse(response); + } +} diff --git a/src/shared/types/remote.types.ts b/src/shared/types/remote.types.ts new file mode 100644 index 0000000..bc9f7fd --- /dev/null +++ b/src/shared/types/remote.types.ts @@ -0,0 +1,42 @@ +// Shared types for Remote Config System (MCED-Remote) + +export interface RemoteConnection { + id: string; + name: string; + host: string; + port: number; + apiKey: string; + lastConnected?: number; +} + +export interface RemoteFile { + path: string; + size: number; + lastModified: number; + isDirectory: boolean; +} + +export interface RemoteStatus { + status: "ok" | "error"; + version: string; + serverName: string; +} + +export interface RemoteServerInfo { + serverName: string; + rootPath: string; + serverType: "modded" | "plugin" | "vanilla" | "unknown"; + modLoader: "fabric" | "forge" | "neoforge" | "paper" | "spigot" | "unknown"; + hasConfigDir: boolean; + hasMods: boolean; + hasPlugins: boolean; + javaVersion: string; + os: string; +} + +export type RemoteConnectionStatus = "idle" | "connecting" | "connected" | "error"; + +export interface RemoteErrorCode { + code: "AUTH_FAILED" | "NOT_FOUND" | "FORBIDDEN" | "CONNECTION_REFUSED" | "TIMEOUT" | "INTERNAL_ERROR" | "UNKNOWN"; + message: string; +} From d35a44664cb2e9906eaae39e25233765e8c5ef39 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 19:52:23 +0000 Subject: [PATCH 2/5] feat: Add Remote Config UI - connection manager, file browser, and integration Completes the MCED-Remote client-side UI layer: **New Components** - RemoteConnectDialog: Add/edit connections with name, host, port, API key fields; includes test-before-save and auto-connect on save - RemoteConnectionManager: Panel listing saved connections with connect/ disconnect/edit/delete actions, active connection status dot, server info - RemoteFileBrowser: Two-pane layout with collapsible directory tree on the left and a plain-text editor on the right; Ctrl+S to save, delete with confirmation dialog **State Management** - remoteConnectionStore: Zustand store tracking UI connection state (idle/connecting/connected/error), active connection ID, cached server info, and the saved-connection list from the main process **App Integration** - Added "remote" view mode to useAppStore (alongside "mods" and "kubejs") - MainPanel: renders the two-pane remote layout when viewMode === "remote" - Header: Server button toggles remote mode; shows green dot when connected https://claude.ai/code/session_01JK8L3K1rDjTudkw7Et6XTm --- src/renderer/components/Header.tsx | 20 +- src/renderer/components/MainPanel.tsx | 20 + .../components/remote/RemoteConnectDialog.tsx | 238 +++++++++++ .../remote/RemoteConnectionManager.tsx | 245 +++++++++++ .../components/remote/RemoteFileBrowser.tsx | 397 ++++++++++++++++++ src/renderer/store.ts | 4 +- src/renderer/store/remoteConnectionStore.ts | 83 ++++ 7 files changed, 1004 insertions(+), 3 deletions(-) create mode 100644 src/renderer/components/remote/RemoteConnectDialog.tsx create mode 100644 src/renderer/components/remote/RemoteConnectionManager.tsx create mode 100644 src/renderer/components/remote/RemoteFileBrowser.tsx create mode 100644 src/renderer/store/remoteConnectionStore.ts diff --git a/src/renderer/components/Header.tsx b/src/renderer/components/Header.tsx index 2575f62..30c91d8 100644 --- a/src/renderer/components/Header.tsx +++ b/src/renderer/components/Header.tsx @@ -13,8 +13,10 @@ import { ChevronDown, Folder, FolderX, + Server, } from "lucide-react"; import { LauncherIcon } from "./LauncherIcon"; +import { useRemoteConnectionStore } from "../store/remoteConnectionStore"; interface HeaderProps { onSearchClick: () => void; @@ -31,7 +33,8 @@ export function Header({ onStatsClick, onChangelogClick, }: HeaderProps) { - const { currentInstance, launcherType } = useAppStore(); + const { currentInstance, launcherType, viewMode, setViewMode } = useAppStore(); + const { connectionStatus } = useRemoteConnectionStore(); const [showSettings, setShowSettings] = useState(false); const [showBackups, setShowBackups] = useState(false); const [showInstanceMenu, setShowInstanceMenu] = useState(false); @@ -207,6 +210,21 @@ export function Header({ )} + + + +

+ Found in mced-remote.properties on the server +

+ + + {/* Test result */} + {testStatus !== "idle" && ( +
+ {testStatus === "testing" && } + {testStatus === "ok" && } + {testStatus === "error" && } + {testStatus === "testing" ? "Testing connection..." : testMessage} +
+ )} + + {/* Actions */} +
+ + +
+ + + +
+
+ + ); +}; diff --git a/src/renderer/components/remote/RemoteConnectionManager.tsx b/src/renderer/components/remote/RemoteConnectionManager.tsx new file mode 100644 index 0000000..1ddaf06 --- /dev/null +++ b/src/renderer/components/remote/RemoteConnectionManager.tsx @@ -0,0 +1,245 @@ +import React, { useEffect, useState } from "react"; +import { + Plus, + Plug, + PlugZap, + Trash2, + Edit2, + Loader2, + Server, + Clock, + AlertCircle, +} from "lucide-react"; +import { useRemoteConnectionStore } from "../../store/remoteConnectionStore"; +import { RemoteConnectDialog } from "./RemoteConnectDialog"; +import { ConfirmDialog } from "../common/Dialog"; +import { RemoteConnection } from "../../../shared/types/remote.types"; + +export const RemoteConnectionManager: React.FC = () => { + const { + savedConnections, + activeConnectionId, + connectionStatus, + connectionError, + serverInfo, + loadConnections, + connect, + disconnect, + deleteConnection, + } = useRemoteConnectionStore(); + + const [showAddDialog, setShowAddDialog] = useState(false); + const [editingConnection, setEditingConnection] = useState | null>(null); + const [deletingId, setDeletingId] = useState(null); + const [connectingId, setConnectingId] = useState(null); + + useEffect(() => { + loadConnections(); + }, []); + + const handleConnect = async (id: string) => { + setConnectingId(id); + await connect(id); + setConnectingId(null); + }; + + const handleDisconnect = async () => { + await disconnect(); + }; + + const handleDelete = async (id: string) => { + await deleteConnection(id); + setDeletingId(null); + }; + + const formatLastConnected = (ts?: number) => { + if (!ts) return "Never"; + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + const hours = Math.floor(mins / 60); + const days = Math.floor(hours / 24); + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (mins > 0) return `${mins}m ago`; + return "Just now"; + }; + + return ( +
+ {/* Header */} +
+
+ +
+

Remote Connections

+ {activeConnectionId && connectionStatus === "connected" && ( +

+ Connected{serverInfo?.serverName ? ` to "${serverInfo.serverName}"` : ""} +

+ )} +
+
+ +
+ + {/* Connection error banner */} + {connectionStatus === "error" && connectionError && ( +
+ + {connectionError} +
+ )} + + {/* Server info */} + {connectionStatus === "connected" && serverInfo && ( +
+
+ {serverInfo.modLoader && serverInfo.modLoader !== "unknown" && ( + + Loader:{" "} + {serverInfo.modLoader.charAt(0).toUpperCase() + serverInfo.modLoader.slice(1)} + + )} + {serverInfo.serverType && serverInfo.serverType !== "unknown" && ( + + Type:{" "} + {serverInfo.serverType.charAt(0).toUpperCase() + serverInfo.serverType.slice(1)} + + )} + {serverInfo.javaVersion && ( + + Java: {serverInfo.javaVersion} + + )} +
+
+ )} + + {/* Connection list */} +
+ {savedConnections.length === 0 ? ( +
+ +

No connections saved yet

+

Add a connection to manage remote server configs

+
+ ) : ( + savedConnections.map((conn) => { + const isActive = conn.id === activeConnectionId; + const isConnecting = connectingId === conn.id; + + return ( +
+ {/* Status dot */} +
+ + {/* Info */} +
+
{conn.name}
+
+ {conn.host}:{conn.port} +
+
+ + {/* Last connected */} +
+ + {formatLastConnected(conn.lastConnected)} +
+ + {/* Actions */} +
+ {isActive ? ( + + ) : ( + + )} + + + + +
+
+ ); + }) + )} +
+ + {/* Setup hint */} +
+
+ Run java -jar mced-remote.jar on your server to get the API key +
+
+ + {/* Dialogs */} + { setShowAddDialog(false); setEditingConnection(null); }} + existingConnection={editingConnection} + /> + + setDeletingId(null)} + onConfirm={() => deletingId && handleDelete(deletingId)} + title="Delete Connection" + message={`Delete "${savedConnections.find((c) => c.id === deletingId)?.name ?? "this connection"}"? This cannot be undone.`} + confirmText="Delete" + variant="danger" + /> +
+ ); +}; diff --git a/src/renderer/components/remote/RemoteFileBrowser.tsx b/src/renderer/components/remote/RemoteFileBrowser.tsx new file mode 100644 index 0000000..564c990 --- /dev/null +++ b/src/renderer/components/remote/RemoteFileBrowser.tsx @@ -0,0 +1,397 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { + Folder, + FolderOpen, + FileText, + ChevronRight, + ChevronDown, + RefreshCw, + Save, + Trash2, + Loader2, + AlertCircle, + ArrowLeft, +} from "lucide-react"; +import { RemoteFile } from "../../../shared/types/remote.types"; +import { useRemoteConnectionStore } from "../../store/remoteConnectionStore"; +import { ConfirmDialog } from "../common/Dialog"; + +interface FileNode extends RemoteFile { + children?: FileNode[]; + loaded?: boolean; + expanded?: boolean; +} + +function getLanguage(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + switch (ext) { + case "json": + case "json5": + return "json"; + case "toml": + return "toml"; + case "yml": + case "yaml": + return "yaml"; + case "properties": + case "cfg": + return "properties"; + default: + return "plaintext"; + } +} + +export const RemoteFileBrowser: React.FC = () => { + const { activeConnectionId, connectionStatus } = useRemoteConnectionStore(); + + const [rootFiles, setRootFiles] = useState([]); + const [loadingRoot, setLoadingRoot] = useState(false); + const [rootError, setRootError] = useState(null); + + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(""); + const [loadingFile, setLoadingFile] = useState(false); + const [fileError, setFileError] = useState(null); + + const [editedContent, setEditedContent] = useState(""); + const [isDirty, setIsDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState(null); + + const [confirmDelete, setConfirmDelete] = useState(false); + + const isConnected = connectionStatus === "connected" && !!activeConnectionId; + + const loadRoot = useCallback(async () => { + if (!isConnected) return; + setLoadingRoot(true); + setRootError(null); + const result = await window.api.remoteListFiles(undefined, false); + setLoadingRoot(false); + if (result.success && result.data) { + setRootFiles( + result.data.map((f: RemoteFile) => ({ ...f, children: f.isDirectory ? [] : undefined, loaded: false, expanded: false })) + ); + } else { + setRootError(result.error ?? "Failed to load files"); + } + }, [isConnected]); + + useEffect(() => { + if (isConnected) { + loadRoot(); + setSelectedFile(null); + setFileContent(""); + setEditedContent(""); + setIsDirty(false); + } else { + setRootFiles([]); + setSelectedFile(null); + } + }, [isConnected, activeConnectionId]); + + const loadDirectory = async (node: FileNode, updateTree: (updater: (nodes: FileNode[]) => FileNode[]) => void) => { + if (!isConnected || !node.isDirectory) return; + const result = await window.api.remoteListFiles(node.path, false); + if (result.success && result.data) { + const children: FileNode[] = result.data.map((f: RemoteFile) => ({ + ...f, + children: f.isDirectory ? [] : undefined, + loaded: false, + expanded: false, + })); + updateTree((prev) => updateNodeInTree(prev, node.path, (n) => ({ ...n, children, loaded: true, expanded: true }))); + } + }; + + function updateNodeInTree( + nodes: FileNode[], + path: string, + updater: (node: FileNode) => FileNode + ): FileNode[] { + return nodes.map((n) => { + if (n.path === path) return updater(n); + if (n.children) return { ...n, children: updateNodeInTree(n.children, path, updater) }; + return n; + }); + } + + const toggleDirectory = async (node: FileNode) => { + if (!node.isDirectory) return; + if (!node.expanded) { + if (!node.loaded) { + await loadDirectory(node, (updater) => setRootFiles(updater)); + } else { + setRootFiles((prev) => + updateNodeInTree(prev, node.path, (n) => ({ ...n, expanded: true })) + ); + } + } else { + setRootFiles((prev) => + updateNodeInTree(prev, node.path, (n) => ({ ...n, expanded: false })) + ); + } + }; + + const openFile = async (node: FileNode) => { + if (node.isDirectory) { + await toggleDirectory(node); + return; + } + if (isDirty && selectedFile) { + const ok = window.confirm("You have unsaved changes. Discard them?"); + if (!ok) return; + } + setSelectedFile(node); + setLoadingFile(true); + setFileError(null); + setIsDirty(false); + setSaveMessage(null); + const result = await window.api.remoteReadFile(node.path); + setLoadingFile(false); + if (result.success && result.data !== undefined) { + setFileContent(result.data); + setEditedContent(result.data); + } else { + setFileError(result.error ?? "Failed to read file"); + } + }; + + const handleSave = async () => { + if (!selectedFile || !isDirty) return; + setSaving(true); + setSaveMessage(null); + const result = await window.api.remoteWriteFile(selectedFile.path, editedContent); + setSaving(false); + if (result.success) { + setFileContent(editedContent); + setIsDirty(false); + setSaveMessage("Saved"); + setTimeout(() => setSaveMessage(null), 2000); + } else { + setSaveMessage(`Error: ${result.error ?? "Failed to save"}`); + } + }; + + const handleDelete = async () => { + if (!selectedFile) return; + const result = await window.api.remoteDeleteFile(selectedFile.path); + setConfirmDelete(false); + if (result.success) { + setSelectedFile(null); + setFileContent(""); + setEditedContent(""); + setIsDirty(false); + await loadRoot(); + } + }; + + const handleEditorChange = (e: React.ChangeEvent) => { + setEditedContent(e.target.value); + setIsDirty(e.target.value !== fileContent); + }; + + const FileNodeRow: React.FC<{ node: FileNode; depth: number }> = ({ node, depth }) => { + const isSelected = selectedFile?.path === node.path; + const indent = depth * 16; + + return ( + <> + + {node.isDirectory && node.expanded && node.children && ( + <> + {node.children.map((child) => ( + + ))} + + )} + + ); + }; + + if (!isConnected) { + return ( +
+ +

Not connected

+

Connect to a remote server to browse and edit its config files

+
+ ); + } + + return ( +
+ {/* File Tree */} +
+
+ Files + +
+
+ {loadingRoot ? ( +
+ +
+ ) : rootError ? ( +
+ + {rootError} +
+ ) : rootFiles.length === 0 ? ( +

No files found

+ ) : ( + rootFiles.map((node) => ) + )} +
+
+ + {/* Editor area */} +
+ {selectedFile ? ( + <> + {/* File toolbar */} +
+ + + {selectedFile.path} + {isDirty && *} + + + {saveMessage && ( + + {saveMessage} + + )} + + + +
+ + {/* Editor */} + {loadingFile ? ( +
+ +
+ ) : fileError ? ( +
+
+ + {fileError} +
+
+ ) : ( +