diff --git a/.github/workflows/build-mced-remote.yml b/.github/workflows/build-mced-remote.yml new file mode 100644 index 0000000..dc493b3 --- /dev/null +++ b/.github/workflows/build-mced-remote.yml @@ -0,0 +1,250 @@ +name: Build & Release MCED-Remote + +# Triggers: +# Branch push: only when mced-remote/ files change (builds + stores artifact) +# Tag push: runs full release pipeline (build + GitHub Release + Modrinth + CurseForge) +# Manual: workflow_dispatch with optional version override and publish toggle +# +# Path filter applies to branch pushes; tag pushes always run regardless of changed files. +on: + push: + branches: [main, master] + tags: + - 'v*' + paths: + - 'mced-remote/**' + workflow_dispatch: + inputs: + version: + description: 'Version (e.g. 1.0.1). Overrides pom.xml. Leave blank to use pom.xml.' + required: false + default: '' + publish: + description: 'Publish to GitHub Releases / Modrinth / CurseForge?' + type: boolean + required: false + default: false + +env: + POM: mced-remote/pom.xml + +jobs: + # ───────────────────────────────────────────────────────────────── + # 1. Build the JAR + # ───────────────────────────────────────────────────────────────── + build: + name: Build JAR + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + # Resolve the version: dispatch input > git tag > pom.xml + - name: Resolve version + id: version + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF_NAME#v}" + else + VERSION=$(mvn -f ${{ env.POM }} help:evaluate \ + -Dexpression=project.version -q -DforceStdout) + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building version: $VERSION" + + - name: Set version in pom.xml + run: | + mvn -f ${{ env.POM }} versions:set \ + -DnewVersion=${{ steps.version.outputs.version }} \ + -DgenerateBackupPoms=false + + - name: Build with Maven + run: mvn -f ${{ env.POM }} package -q --no-transfer-progress + + - name: List build output + run: ls -lh mced-remote/target/mced-remote*.jar + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: mced-remote-${{ steps.version.outputs.version }} + path: | + mced-remote/target/mced-remote.jar + mced-remote/target/mced-remote-${{ steps.version.outputs.version }}.jar + retention-days: 30 + if-no-files-found: error + + # ───────────────────────────────────────────────────────────────── + # 2. GitHub Release + # Runs on: version tags OR manual dispatch with publish=true + # ───────────────────────────────────────────────────────────────── + github-release: + name: GitHub Release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + if: | + startsWith(github.ref, 'refs/tags/v') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: mced-remote-${{ needs.build.outputs.version }} + path: dist/ + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: "MCED-Remote v${{ needs.build.outputs.version }}" + body: | + ## MCED-Remote v${{ needs.build.outputs.version }} + + Standalone HTTP agent for **MCED Desktop** — place it on your Minecraft server + so the desktop app can remotely browse and edit config files. + + ### Quick Start + 1. Copy `mced-remote.jar` into your Minecraft server root directory + 2. Run `java -jar mced-remote.jar` + 3. On first run it creates `mced-remote.properties` with an auto-generated API key + 4. In MCED Desktop → click the **Server** icon → **Add Connection** → paste the key + + ### Requirements + - Java 17 or newer (no mod loader required) + - Works with Vanilla, Fabric, Forge, NeoForge, Paper, and any other server type + + ### Files + | File | Description | + |------|-------------| + | `mced-remote.jar` | Drop this on your server and run it | + | `mced-remote-${{ needs.build.outputs.version }}.jar` | Same JAR, versioned filename | + files: | + dist/mced-remote.jar + dist/mced-remote-${{ needs.build.outputs.version }}.jar + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ───────────────────────────────────────────────────────────────── + # 3. Modrinth Upload + # Set repository variable MODRINTH_PROJECT_ID and secret MODRINTH_TOKEN to enable. + # ───────────────────────────────────────────────────────────────── + modrinth: + name: Upload to Modrinth + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + if: | + (startsWith(github.ref, 'refs/tags/v') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true')) && + vars.MODRINTH_PROJECT_ID != '' + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: mced-remote-${{ needs.build.outputs.version }} + path: dist/ + + - name: Upload to Modrinth + env: + MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} + PROJECT_ID: ${{ vars.MODRINTH_PROJECT_ID }} + VERSION: ${{ needs.build.outputs.version }} + run: | + JAR="dist/mced-remote-${VERSION}.jar" + + # Modrinth v2 API: POST /version with multipart form + # Docs: https://docs.modrinth.com/api/operations/createversion/ + RESPONSE=$(curl -sf \ + -X POST \ + -H "Authorization: $MODRINTH_TOKEN" \ + -F "data={ + \"name\": \"v${VERSION}\", + \"version_number\": \"${VERSION}\", + \"changelog\": \"See the [GitHub release](https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}) for details.\", + \"dependencies\": [], + \"game_versions\": [], + \"version_type\": \"release\", + \"loaders\": [\"datapack\"], + \"featured\": true, + \"project_id\": \"${PROJECT_ID}\", + \"file_parts\": [\"mced-remote-${VERSION}.jar\"], + \"primary_file\": \"mced-remote-${VERSION}.jar\" + };type=application/json" \ + -F "mced-remote-${VERSION}.jar=@${JAR}" \ + "https://api.modrinth.com/v2/version") \ + || { echo "::error::Modrinth upload failed"; exit 1; } + + echo "Modrinth response: $RESPONSE" + echo "Modrinth upload complete" + + # ───────────────────────────────────────────────────────────────── + # 4. CurseForge Upload + # Set repository variable CURSEFORGE_PROJECT_ID and secret CURSEFORGE_TOKEN to enable. + # ───────────────────────────────────────────────────────────────── + curseforge: + name: Upload to CurseForge + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + if: | + (startsWith(github.ref, 'refs/tags/v') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true')) && + vars.CURSEFORGE_PROJECT_ID != '' + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: mced-remote-${{ needs.build.outputs.version }} + path: dist/ + + - name: Upload to CurseForge + env: + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + PROJECT_ID: ${{ vars.CURSEFORGE_PROJECT_ID }} + VERSION: ${{ needs.build.outputs.version }} + run: | + JAR="dist/mced-remote-${VERSION}.jar" + + # CurseForge Upload API: https://support.curseforge.com/en/support/solutions/articles/9000197321 + METADATA=$(cat <> "$GITHUB_OUTPUT" + + - name: Set version in pom.xml + run: | + mvn -f mced-remote/pom.xml versions:set \ + -DnewVersion=${{ steps.version.outputs.version }} \ + -DgenerateBackupPoms=false + + - name: Build JAR + run: mvn -f mced-remote/pom.xml package -q --no-transfer-progress + + - name: Upload JAR artifact + uses: actions/upload-artifact@v4 + with: + name: mced-remote-jar + path: | + mced-remote/target/mced-remote.jar + mced-remote/target/mced-remote-${{ steps.version.outputs.version }}.jar + retention-days: 1 + if-no-files-found: error + + # Build and publish the Electron app (matrix across OS) release: + needs: build-java runs-on: ${{ matrix.os }} permissions: contents: write - + strategy: fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] - + steps: - name: Check out Git repository uses: actions/checkout@v4 @@ -62,3 +111,28 @@ jobs: run: npx electron-builder --linux --publish always env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Attach the MCED-Remote JAR to the same GitHub Release + attach-jar: + name: Attach MCED-Remote JAR to Release + needs: [build-java, release] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download JAR artifact + uses: actions/download-artifact@v4 + with: + name: mced-remote-jar + path: dist/ + + - name: Upload JARs to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: | + dist/mced-remote.jar + dist/mced-remote-${{ needs.build-java.outputs.version }}.jar + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/mced-remote/pom.xml b/mced-remote/pom.xml new file mode 100644 index 0000000..27a05ae --- /dev/null +++ b/mced-remote/pom.xml @@ -0,0 +1,93 @@ + + + 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) + https://github.com/MinecraftEvolve/MCED + + + 17 + 17 + UTF-8 + + mced-remote + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.mced.remote.MCEDRemote + true + + + ${project.version} + ${project.name} + + + ${jar.deployName} + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.6.0 + + + + com.mced.remote.MCEDRemote + true + + + ${project.version} + ${project.name} + + + + jar-with-dependencies + + ${jar.deployName}-${project.version} + + false + + + + make-release-jar + package + single + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + UTF-8 + + + + + 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/package.json b/package.json index f26f469..8b55c35 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "build": "npm run build:main && npm run build:renderer", "build:main": "tsc --project src/main/tsconfig.main.json", "build:renderer": "vite build", + "build:java": "mvn -f mced-remote/pom.xml package -q --no-transfer-progress", + "build:all": "npm run build && npm run build:java", "package": "electron-builder", "package:win": "electron-builder --win --publish never", "package:mac": "electron-builder --mac --publish never", 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/renderer/App.tsx b/src/renderer/App.tsx index a475579..6082b92 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4,7 +4,7 @@ import { useSettingsStore } from "./store/settingsStore"; import { useStatsStore } from "./store/statsStore"; import { useChangelogStore } from "./store/changelogStore"; import { NotificationProvider } from "./components/common/Notifications"; -import { Loader2, Settings as SettingsIcon, FolderOpen } from "lucide-react"; +import { Loader2, Settings as SettingsIcon, FolderOpen, Server } from "lucide-react"; import { Header } from "./components/Header"; import { Sidebar } from "./components/Sidebar"; import { MainPanel } from "./components/MainPanel"; @@ -108,6 +108,8 @@ function App() { recentInstances, addRecentInstance, setLauncherType, + viewMode, + setViewMode, } = useAppStore(); const { settings } = useSettingsStore(); const { startSession, endSession } = useStatsStore(); @@ -369,6 +371,31 @@ function App() { ) : null; + // Remote mode works without a local instance - show the full app shell + if (!currentInstance && viewMode === "remote") { + return ( + + <> + {LoadingOverlay} +
+
setShowSearch(true)} + onOpenInstance={handleOpenInstance} + onCloseInstance={handleCloseInstance} + onStatsClick={() => setShowStats(true)} + onChangelogClick={() => setShowChangelog(true)} + /> +
+ +
+
+ + {showSettings && setShowSettings(false)} />} + +
+ ); + } + if (!currentInstance) { return ( <> @@ -413,6 +440,15 @@ function App() { Open Instance + + {recentInstances && recentInstances.length > 0 && (

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..cfa837c --- /dev/null +++ b/src/renderer/components/remote/RemoteFileBrowser.tsx @@ -0,0 +1,424 @@ +import React, { useEffect, useState, useCallback, useRef } from "react"; +import Editor from "@monaco-editor/react"; +import { + Folder, + FolderOpen, + FileText, + ChevronRight, + ChevronDown, + RefreshCw, + Save, + Trash2, + Loader2, + AlertCircle, + ArrowLeft, + Server, +} from "lucide-react"; +import { RemoteFile } from "../../../shared/types/remote.types"; +import { useRemoteConnectionStore } from "../../store/remoteConnectionStore"; +import { ConfirmDialog } from "../common/Dialog"; +import { useSettingsStore } from "../../store/settingsStore"; + +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 "ini"; // Monaco has no native TOML; ini is close + case "yml": + case "yaml": + return "yaml"; + case "properties": + case "cfg": + return "ini"; + case "js": + case "ts": + return "javascript"; + default: + return "plaintext"; + } +} + +export const RemoteFileBrowser: React.FC = () => { + const { activeConnectionId, connectionStatus } = useRemoteConnectionStore(); + const { settings } = useSettingsStore(); + const editorRef = useRef(null); + + 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 editorTheme = settings.theme === "light" ? "vs-light" : "vs-dark"; + + 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 updateNodeInTree = ( + nodes: FileNode[], + path: string, + updater: (node: FileNode) => FileNode + ): FileNode[] => + nodes.map((n) => { + if (n.path === path) return updater(n); + if (n.children) return { ...n, children: updateNodeInTree(n.children, path, updater) }; + return n; + }); + + const loadDirectory = async (node: FileNode) => { + 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, + })); + setRootFiles((prev) => + updateNodeInTree(prev, node.path, (n) => ({ ...n, children, loaded: true, expanded: true })) + ); + } + }; + + const toggleDirectory = async (node: FileNode) => { + if (!node.expanded) { + if (!node.loaded) { + await loadDirectory(node); + } 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 = useCallback(async () => { + if (!selectedFile || !isDirty) return; + setSaving(true); + setSaveMessage(null); + const contentToSave = editorRef.current?.getValue() ?? editedContent; + const result = await window.api.remoteWriteFile(selectedFile.path, contentToSave); + setSaving(false); + if (result.success) { + setFileContent(contentToSave); + setEditedContent(contentToSave); + setIsDirty(false); + setSaveMessage("Saved"); + setTimeout(() => setSaveMessage(null), 2000); + } else { + setSaveMessage(`Error: ${result.error ?? "Failed to save"}`); + } + }, [selectedFile, isDirty, editedContent]); + + 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 handleEditorMount = (editor: any, monaco: any) => { + editorRef.current = editor; + editor.updateOptions({ + fontSize: 13, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: "off", + automaticLayout: true, + tabSize: 2, + insertSpaces: true, + }); + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + handleSave(); + }); + }; + + const handleEditorChange = (value: string | undefined) => { + if (value !== undefined) { + setEditedContent(value); + setIsDirty(value !== fileContent); + } + }; + + const FileNodeRow: React.FC<{ node: FileNode; depth: number }> = ({ node, depth }) => { + const isSelected = selectedFile?.path === node.path; + 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 ? ( + <> + {/* Toolbar */} +
+ + + {selectedFile.path} + {isDirty && } + + + {saveMessage && ( + + {saveMessage} + + )} + + + +
+ + {/* Monaco Editor or states */} +
+ {loadingFile ? ( +
+ +
+ ) : fileError ? ( +
+
+ + {fileError} +
+
+ ) : ( + + )} +
+ + {/* Footer hint */} +
+ Ctrl+S to save + · + {getLanguage(selectedFile.path)} + {selectedFile.size > 0 && ( + <> + · + {(selectedFile.size / 1024).toFixed(1)} KB + + )} +
+ + ) : ( +
+
+ +

Select a file from the tree to edit

+
+
+ )} +
+ + setConfirmDelete(false)} + onConfirm={handleDelete} + title="Delete File" + message={`Delete "${selectedFile?.name}"? This cannot be undone.`} + confirmText="Delete" + variant="danger" + /> +
+ ); +}; diff --git a/src/renderer/store.ts b/src/renderer/store.ts index 7ab5aa7..f5922cf 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -36,8 +36,8 @@ interface AppState { setConfigFiles: (files: ConfigFile[]) => void; // KubeJS - viewMode: "mods" | "kubejs"; - setViewMode: (mode: "mods" | "kubejs") => void; + viewMode: "mods" | "kubejs" | "remote"; + setViewMode: (mode: "mods" | "kubejs" | "remote") => void; kubeJSDetected: boolean; setKubeJSDetected: (detected: boolean) => void; diff --git a/src/renderer/store/remoteConnectionStore.ts b/src/renderer/store/remoteConnectionStore.ts new file mode 100644 index 0000000..ce24e3f --- /dev/null +++ b/src/renderer/store/remoteConnectionStore.ts @@ -0,0 +1,114 @@ +import { create } from "zustand"; +import { RemoteConnection, RemoteConnectionStatus, RemoteServerInfo } from "../../shared/types/remote.types"; + +const POLL_INTERVAL_MS = 30_000; + +interface RemoteConnectionStore { + // Cached from main process (no API keys) + savedConnections: Omit[]; + + // Session state + activeConnectionId: string | null; + connectionStatus: RemoteConnectionStatus; + connectionError: string | null; + serverInfo: RemoteServerInfo | null; + + // Actions + loadConnections: () => Promise; + saveConnection: (connection: RemoteConnection) => Promise<{ success: boolean; error?: string }>; + deleteConnection: (id: string) => Promise<{ success: boolean; error?: string }>; + connect: (connectionId: string) => Promise<{ success: boolean; error?: string }>; + disconnect: () => Promise; + setConnectionStatus: (status: RemoteConnectionStatus, error?: string) => void; +} + +let pollTimer: ReturnType | null = null; + +function stopPolling() { + if (pollTimer !== null) { + clearInterval(pollTimer); + pollTimer = null; + } +} + +function startPolling(store: RemoteConnectionStore) { + stopPolling(); + pollTimer = setInterval(async () => { + // Only poll if still connected + const state = useRemoteConnectionStore.getState(); + if (state.connectionStatus !== "connected" || !state.activeConnectionId) { + stopPolling(); + return; + } + const result = await window.api.remoteGetInfo(); + if (!result.success) { + useRemoteConnectionStore.setState({ + connectionStatus: "error", + connectionError: "Connection lost: " + (result.error ?? "server unreachable"), + }); + stopPolling(); + } + }, POLL_INTERVAL_MS); +} + +export const useRemoteConnectionStore = create((set, get) => ({ + savedConnections: [], + activeConnectionId: null, + connectionStatus: "idle", + connectionError: null, + serverInfo: null, + + loadConnections: async () => { + const result = await window.api.remoteGetSavedConnections(); + if (result.success && result.data) { + set({ savedConnections: result.data as Omit[] }); + } + }, + + saveConnection: async (connection) => { + const result = await window.api.remoteSaveConnection(connection); + if (result.success) { + await get().loadConnections(); + } + return result; + }, + + deleteConnection: async (id) => { + const result = await window.api.remoteDeleteConnection(id); + if (result.success) { + await get().loadConnections(); + if (get().activeConnectionId === id) { + stopPolling(); + set({ activeConnectionId: null, connectionStatus: "idle", connectionError: null, serverInfo: null }); + } + } + return result; + }, + + connect: async (connectionId) => { + set({ connectionStatus: "connecting", connectionError: null }); + const result = await window.api.remoteConnect(connectionId); + if (result.success) { + set({ activeConnectionId: connectionId, connectionStatus: "connected" }); + const infoResult = await window.api.remoteGetInfo(); + if (infoResult.success && infoResult.data) { + set({ serverInfo: infoResult.data as RemoteServerInfo }); + } + await get().loadConnections(); + startPolling(get()); + } else { + set({ connectionStatus: "error", connectionError: result.error ?? "Connection failed" }); + } + return result; + }, + + disconnect: async () => { + stopPolling(); + await window.api.remoteDisconnect(); + set({ activeConnectionId: null, connectionStatus: "idle", connectionError: null, serverInfo: null }); + }, + + setConnectionStatus: (status, error) => { + set({ connectionStatus: status, connectionError: error ?? null }); + }, +})); 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; +}