diff --git a/README.md b/README.md index 483865d..66e94d4 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,88 @@ -![catalyst](/.github/assets/banner.png) +![Catalyst banner](.github/assets/banner.png) # Catalyst -[![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](LICENSE) -[![Platform](https://img.shields.io/badge/Platform-Windows-lightgrey.svg)](#installation) -[![Electron](https://img.shields.io/badge/Electron-33-47848F.svg?logo=electron&logoColor=white)](https://www.electronjs.org/) +[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE) +[![Platform: Windows](https://img.shields.io/badge/platform-Windows-111827.svg)](#installation) +[![Electron 33](https://img.shields.io/badge/Electron-33-47848F.svg?logo=electron&logoColor=white)](https://www.electronjs.org/) -**A modern, feature-rich Minecraft server launcher and manager.** +Catalyst is a desktop manager for Minecraft servers. It helps you create, run, configure, back up, and monitor multiple servers from one Windows app. -Catalyst makes it easy to create, configure, and manage multiple Minecraft servers from a single desktop application. With automatic Java management, plugin support via Modrinth, built-in analytics, and a sleek dark UI — running a Minecraft server has never been simpler. +## Highlights ---- +- Manage multiple Vanilla, Paper, Fabric, Forge, and Purpur servers +- Download and select the right Java runtime for each Minecraft version +- Use a built-in live console for server output and commands +- Search and install plugins from Modrinth +- Track TPS, memory, player activity, and events with CatalystAnalytics +- Schedule backups for worlds and server files +- Edit server settings, whitelist, and ban list from the UI +- Share a server through the built-in ngrok integration -## ✨ Features +## Installation -- 🖥️ **Multi-Server Management** — Create and run multiple Minecraft servers (Vanilla, Paper, Fabric, Forge, and more) -- ☕ **Automatic Java Runtime** — Downloads and manages the correct Java version (8, 11, 17, 21) based on your Minecraft version -- 📟 **Real-Time Console** — Integrated server console with live output -- 🔌 **Plugin Management** — Search, install, and update plugins directly from Modrinth -- 📊 **Server Analytics** — Bundled CatalystAnalytics plugin for TPS, memory usage, player stats, and geo-location tracking -- 💾 **Automated Backups** — Configurable backup schedules to keep your worlds safe -- 🌐 **Ngrok Integration** — Share your server instantly with built-in tunnel support -- ⚙️ **Server Properties Editor** — Edit server.properties, whitelist, and banlist from the UI -- 🔄 **Auto-Updates** — Built-in update checker to stay on the latest version -- 💻 **Cross-Platform** — Supports Windows (Linux coming soon) +Download the latest Windows installer from [GitHub Releases](../../releases). ---- +| Platform | Status | Format | +| --- | --- | --- | +| Windows | Supported | `.exe` installer | +| Linux | Planned | Not available yet | -## 📥 Installation +## Development -Download the latest release from [**GitHub Releases**](../../releases). +### Requirements -| Platform | Format | -|----------|--------| -| Windows | `.exe` installer | -| Linux | 🚧 Coming Soon | - ---- - -## 🛠️ Development - -### Prerequisites - -- [Node.js](https://nodejs.org/) 20+ -- [npm](https://www.npmjs.com/) (included with Node.js) +- [Node.js](https://nodejs.org/) 20 or newer +- npm, included with Node.js +- Windows for packaged desktop builds ### Setup ```bash -# Install dependencies npm install - -# Start in development mode npm run dev ``` +The dev command starts the Electron app and the renderer dev server. + +If `npm run dev` fails with `Error: spawn EPERM`, your terminal, antivirus, or sandbox may be blocking Node from starting the esbuild/Electron child process. Run the same command from a normal trusted terminal and allow Node/Electron when Windows or your antivirus asks. + ### Build ```bash -# Windows npm run build:win +``` -# Linux -# 🚧 Coming Soon +For an unpacked local build: +```bash +npm run build:unpack ``` -### Testing +### Checks ```bash -# Run unit tests +npm run typecheck npm run test:unit - -# Run tests with coverage npm run coverage ``` ---- +## CatalystAnalytics + +The desktop app ships with `resources/plugins/CatalystAnalytics.jar`. When analytics are enabled for a Paper or Purpur server, Catalyst copies the plugin into the server's `plugins/` directory and keeps it installed on future starts. -## 🧰 Tech Stack +The plugin exposes a local REST API for dashboard data such as TPS, memory usage, joins, leaves, play time, chat counts, commands, kills, deaths, and approximate player location. Plugin details live in [catalyst-plugin/README.md](catalyst-plugin/README.md). -| Category | Technology | -|----------|------------| -| **Framework** | [Electron 33](https://www.electronjs.org/) with [electron-vite](https://electron-vite.org/) | -| **Language** | [TypeScript](https://www.typescriptlang.org/) | -| **Frontend** | [React 19](https://react.dev/) | -| **Styling** | [Tailwind CSS 3](https://tailwindcss.com/) + [shadcn/ui](https://ui.shadcn.com/) | -| **3D Graphics** | [Three.js](https://threejs.org/) | -| **Testing** | [Vitest](https://vitest.dev/) + [Testing Library](https://testing-library.com/) | +## Tech Stack ---- +| Area | Tools | +| --- | --- | +| Desktop | Electron 33, electron-vite | +| UI | React 19, Tailwind CSS, shadcn/ui | +| Language | TypeScript | +| Charts and visuals | Recharts, Three.js | +| Testing | Vitest, Testing Library | -## 📄 License +## License -This project is licensed under the [GNU General Public License v3.0](LICENSE). +Catalyst is licensed under the [GNU General Public License v3.0](LICENSE). diff --git a/package.json b/package.json index 1879a87..6c88661 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "catalyst", "version": "1.2.0", - "description": "Catalyst — A modern Minecraft server manager", + "description": "Catalyst - A modern Minecraft server manager", "main": "./out/main/index.js", "author": "", "homepage": "", diff --git a/src/main/lib/server-runner.ts b/src/main/lib/server-runner.ts index e32c1a8..33ed4c7 100644 --- a/src/main/lib/server-runner.ts +++ b/src/main/lib/server-runner.ts @@ -592,24 +592,38 @@ export async function stopServer( } return new Promise((resolve) => { - const onClose = () => { + let settled = false; + let timeout: ReturnType; + + const finishStopped = async () => { + if (settled) return; + settled = true; clearTimeout(timeout); + runningServers.delete(serverId); + stopStatsPolling(serverId); // Clear the ngrok URL from server record when server stops - updateServerSettings(serverId, { ngrokUrl: undefined }).catch(() => {}); + await updateServerSettings(serverId, { ngrokUrl: undefined }).catch(() => {}); + await updateServerStatus(serverId, "Offline", "0/20"); + if (mainWindow && !mainWindow.isDestroyed()) { + sendStatusUpdate(mainWindow, { serverId, status: "Offline", players: "0/20" }); + } resolve({ success: true }); }; + const onClose = () => { + finishStopped(); + }; + // Send graceful "stop" command to MC server if (child.stdin && !child.stdin.destroyed) { child.stdin.write("stop\n"); } - const timeout = setTimeout(() => { - child.removeListener("close", onClose); + timeout = setTimeout(() => { child.kill("SIGKILL"); // Clear the ngrok URL from server record when server stops updateServerSettings(serverId, { ngrokUrl: undefined }).catch(() => {}); - resolve({ success: true }); + setTimeout(finishStopped, 1500); }, 15000); child.once("close", onClose); diff --git a/src/renderer/src/components/AnimatedOutlet.tsx b/src/renderer/src/components/AnimatedOutlet.tsx index f0c1a99..693a513 100644 --- a/src/renderer/src/components/AnimatedOutlet.tsx +++ b/src/renderer/src/components/AnimatedOutlet.tsx @@ -1,11 +1,28 @@ -import { useOutlet } from "react-router-dom" +import { useLocation, useOutlet } from "react-router-dom" +import { AnimatePresence, motion } from "motion/react" export function AnimatedOutlet() { const outlet = useOutlet() + const location = useLocation() return ( -
- {outlet} +
+ + + {outlet} + +
) } diff --git a/src/renderer/src/components/app-sidebar.tsx b/src/renderer/src/components/app-sidebar.tsx index 3d38b4a..c7cb4ca 100644 --- a/src/renderer/src/components/app-sidebar.tsx +++ b/src/renderer/src/components/app-sidebar.tsx @@ -24,6 +24,7 @@ function CatalystMark() { export function AppSidebar() { const location = useLocation() const servers = useServerStore((state) => state.servers) + const loaded = useServerStore((state) => state.loaded) const runningCount = servers.filter((server) => server.status === "Online").length const isItemActive = (path: string) => @@ -74,12 +75,14 @@ export function AppSidebar() { 0 ? "status-dot-online" : "status-dot-offline"}`} />
- {runningCount > 0 + {!loaded + ? "Syncing servers" + : runningCount > 0 ? `${runningCount} server${runningCount === 1 ? "" : "s"} running` : "All servers stopped"}
- {servers.length} configured + {loaded ? `${servers.length} configured` : "Loading..."}
diff --git a/src/renderer/src/components/ui/animated-number.tsx b/src/renderer/src/components/ui/animated-number.tsx index b1d3046..15bd214 100644 --- a/src/renderer/src/components/ui/animated-number.tsx +++ b/src/renderer/src/components/ui/animated-number.tsx @@ -12,7 +12,7 @@ interface AnimatedNumberProps { * so there are no per-frame React re-renders. */ export function AnimatedNumber({ value, format, className }: AnimatedNumberProps) { - const spring = useSpring(0, { stiffness: 100, damping: 22 }) + const spring = useSpring(value, { stiffness: 100, damping: 22 }) useEffect(() => { spring.set(value) diff --git a/src/renderer/src/pages/DashboardPage.tsx b/src/renderer/src/pages/DashboardPage.tsx index 8fd8e7b..90c5d91 100644 --- a/src/renderer/src/pages/DashboardPage.tsx +++ b/src/renderer/src/pages/DashboardPage.tsx @@ -17,6 +17,31 @@ import { RadialGauge } from "@/components/ui/radial-gauge" import { AnimatedNumber } from "@/components/ui/animated-number" import { useServerStore, type ServerEvent } from "@/stores/serverStore" +const DASHBOARD_EASE = [0.22, 1, 0.36, 1] as const + +const dashboardSequence = { + hidden: {}, + show: { + transition: { + staggerChildren: 0.07, + delayChildren: 0.04, + }, + }, +} + +const dashboardItem = { + hidden: { opacity: 0, y: 24, scale: 0.985 }, + show: { + opacity: 1, + y: 0, + scale: 1, + transition: { + duration: 0.46, + ease: DASHBOARD_EASE, + }, + }, +} + function parsePlayers(value: string | undefined) { const match = value?.match(/(\d+)\s*\/\s*(\d+)/) return { @@ -301,9 +326,17 @@ export function DashboardPage() { } return ( -
+ {/* Hero row — greeting left, big live numbers right */} -
+

Dashboard

@@ -313,24 +346,36 @@ export function DashboardPage() {

-
- - 0} /> - 0} /> - (v >= 1024 ? `${(v / 1024).toFixed(1)}` : `${Math.round(v)}`)} - active={memTotals.used > 0} - /> -
-
+ + + + + + 0} /> + + + 0} /> + + + (v >= 1024 ? `${(v / 1024).toFixed(1)}` : `${Math.round(v)}`)} + active={memTotals.used > 0} + /> + + +
{/* Bento grid */} -
+ {/* Servers — wide card */} -

Servers

@@ -356,13 +401,14 @@ export function DashboardPage() { ))}
-
+ {/* Right column */} -
+ {/* Live performance gauge */} -

Live performance

@@ -380,10 +426,10 @@ export function DashboardPage() { {avgTps != null ? avgTps.toFixed(1) : "—"}
-
+ {/* Action pair — one lime, one violet */} -
+ -
+
{/* Activity */} -

Activity

-
-
-
-
+ + + + ) } diff --git a/src/renderer/src/pages/ServerDetailPage.tsx b/src/renderer/src/pages/ServerDetailPage.tsx index 73c0e93..484e565 100644 --- a/src/renderer/src/pages/ServerDetailPage.tsx +++ b/src/renderer/src/pages/ServerDetailPage.tsx @@ -645,6 +645,9 @@ export function ServerDetailPage() { if (!result.success) { setError(result.error || "Failed to stop server") await refreshServers() + } else { + setServer((prev) => prev ? { ...prev, status: "Offline", players: "0/20" } : prev) + await refreshServers() } setStopping(false) } diff --git a/src/renderer/src/stores/serverStore.ts b/src/renderer/src/stores/serverStore.ts index 2b20a06..00e1ac3 100644 --- a/src/renderer/src/stores/serverStore.ts +++ b/src/renderer/src/stores/serverStore.ts @@ -85,7 +85,9 @@ export const useServerStore = create((set) => ({ stopServer: async (id) => { useServerStore.getState().applyStatus({ serverId: id, status: 'Stopping' }) const result = await window.context.stopServer(id) - if (!result?.success) { + if (result?.success) { + useServerStore.getState().applyStatus({ serverId: id, status: 'Offline', players: '0/20' }) + } else { useServerStore.getState().refresh() } },