Phase 1/2: Tauri shell, React UI (welcome, read-only import, overview with start/stop), e2e driver
Verified in the native window against a disposable fixture: import writes only <folder>.manager + registry; start surfaces a human 'database update required' message; stop returns to Offline. Live install untouched throughout. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{ "name": "vite", "runtimeExecutable": "npx", "runtimeArgs": ["vite"], "port": 1420 }
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/coa-core"]
|
||||
members = ["crates/coa-core", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# CoA Server Manager
|
||||
|
||||
Windows desktop manager for local Conquest of Azeroth (AzerothCore) servers. Tauri 2 + Rust + React.
|
||||
Design and audit: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Status
|
||||
| Phase | State |
|
||||
|-------|-------|
|
||||
| 0 Audit + architecture | done |
|
||||
| 1 Foundation (fsx, registry, manifest, logging, error catalogue) | done |
|
||||
| 2 Read-only import, process observation, Start/Stop via repack launcher, health diagnosis | done |
|
||||
| 3 Config UI, 4 Backups, 5 Clean install, 6 Updates/CI, 7 Bots, 8 Client, 9 Friends, 10 Polish | not started |
|
||||
|
||||
## Develop
|
||||
```
|
||||
npm install
|
||||
cargo test --workspace # core logic (scan, fsx, manifest, process, driver, health)
|
||||
npx vite # UI in a browser uses a mock IPC (src/dev/mock.ts)
|
||||
cargo build -p coa-server-manager # native app (debug build loads http://localhost:1420)
|
||||
```
|
||||
Live checks against a real repack: `cargo run -p coa-core --example scan -- <folder>` (read-only).
|
||||
Start/stop testing must use a disposable copy: `tools/make-fixture.ps1` builds `C:\games\coa-fixture`
|
||||
on offset ports from the pristine packaged database. Never run start/stop against a server in use.
|
||||
`tests/e2e/cdp.mjs` drives the real window through WebView2 remote debugging
|
||||
(`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222`).
|
||||
@@ -41,6 +41,7 @@ pub struct DriverOutcome {
|
||||
pub exit_code: Option<i32>,
|
||||
/// Human-facing classification of a failure.
|
||||
pub code: Option<ErrorCode>,
|
||||
pub human: Option<crate::error::Human>,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
@@ -128,7 +129,7 @@ pub fn run(root: &Path, verb: Verb) -> Result<DriverOutcome> {
|
||||
}
|
||||
};
|
||||
tracing::info!(?verb, ok, ?code, "driver: launcher verb finished");
|
||||
Ok(DriverOutcome { ok, exit_code, code, output })
|
||||
Ok(DriverOutcome { ok, exit_code, code, human: code.map(ErrorCode::human), output })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -125,7 +125,7 @@ fn hash_exe(path: &Path, release: Option<&ReleaseJson>, name: &str) -> Option<Ex
|
||||
Some(ExeInfo { size: meta.len(), sha256, matches_release })
|
||||
}
|
||||
|
||||
fn read_ports(root: &Path) -> Ports {
|
||||
pub fn read_ports(root: &Path) -> Ports {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct P {
|
||||
@@ -195,6 +195,9 @@ fn count_bot_keys(conf: &Path) -> usize {
|
||||
|
||||
/// Scan `root` without modifying it.
|
||||
pub fn scan(root: &Path) -> Result<ScanReport> {
|
||||
if !root.is_dir() {
|
||||
return Err(crate::error::Error::Invalid(format!("{} is not a folder", root.display())));
|
||||
}
|
||||
let root = fsx::canonicalize_lenient(root)?;
|
||||
let mut notes = Vec::new();
|
||||
|
||||
@@ -402,6 +405,11 @@ mod tests {
|
||||
assert_eq!(scan(&root).unwrap().classification, Classification::UnknownCustom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_folder_is_an_error_not_a_verdict() {
|
||||
assert!(scan(Path::new("C:/definitely/not/here")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_and_random_folders_are_incompatible() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CoA Server Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "coa-server-manager",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.49.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"tailwind-merge": "^3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/cli": "^2.12.0",
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "coa-server-manager"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "coa_server_manager_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
coa-core = { path = "../crates/coa-core" }
|
||||
tracing = "0.1"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "UI may call our own commands and open the folder picker. No filesystem, shell or http scope is granted to the frontend.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "dialog:allow-open"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,182 @@
|
||||
//! Thin Tauri command layer. Every mutating command takes an installation *id* and resolves the path from the
|
||||
//! registry; the frontend can never ask the backend to touch an arbitrary path (scan is read-only).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use coa_core::driver::{self, DriverOutcome, Verb};
|
||||
use coa_core::error::UiError;
|
||||
use coa_core::layout::{self, Classification, ScanReport};
|
||||
use coa_core::process::{self, Observed};
|
||||
use coa_core::registry::{metadata_dir_for, InstallKind, InstallMeta, MetaDir, Registry};
|
||||
use coa_core::{Error, Result};
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
struct AppState {
|
||||
registry: Registry,
|
||||
/// Installation ids with a start/stop currently running (one action at a time per server).
|
||||
busy: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ServerSummary {
|
||||
id: String,
|
||||
name: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusView {
|
||||
observed: Observed,
|
||||
busy: bool,
|
||||
path_exists: bool,
|
||||
}
|
||||
|
||||
fn data_dir() -> PathBuf {
|
||||
let base = std::env::var_os("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
|
||||
base.join("CoAServerManager")
|
||||
}
|
||||
|
||||
fn path_of(state: &AppState, id: &str) -> Result<PathBuf> {
|
||||
state
|
||||
.registry
|
||||
.list()?
|
||||
.into_iter()
|
||||
.find(|(i, _)| i == id)
|
||||
.map(|(_, p)| p)
|
||||
.ok_or_else(|| Error::UnknownInstallation(id.to_string()))
|
||||
}
|
||||
|
||||
fn summary(id: String, path: PathBuf) -> ServerSummary {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Server".into());
|
||||
ServerSummary { id, name, path: path.to_string_lossy().into_owned() }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn default_install_dir() -> String {
|
||||
"C:\\Games\\CoA Server".into()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn scan_server(path: String) -> std::result::Result<ScanReport, UiError> {
|
||||
tauri::async_runtime::spawn_blocking(move || layout::scan(std::path::Path::new(&path)))
|
||||
.await
|
||||
.map_err(|e| Error::Invalid(e.to_string()))?
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Import: writes only `<folder>.manager` and the registry entry. The server folder is never modified.
|
||||
#[tauri::command]
|
||||
async fn add_server(state: State<'_, AppState>, path: String) -> std::result::Result<ServerSummary, UiError> {
|
||||
let root = PathBuf::from(&path);
|
||||
let report = tauri::async_runtime::spawn_blocking({
|
||||
let root = root.clone();
|
||||
move || layout::scan(&root)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Invalid(e.to_string()))??;
|
||||
|
||||
if report.classification == Classification::Incompatible {
|
||||
return Err(Error::Invalid("This folder is not a CoA server folder.".into()).into());
|
||||
}
|
||||
if let Some(existing) = state.registry.find_by_path(&root)? {
|
||||
return Ok(summary(existing, PathBuf::from(report.path)));
|
||||
}
|
||||
|
||||
let mut meta = InstallMeta::new(InstallKind::Imported, std::path::Path::new(&report.path));
|
||||
meta.core.commit = report.release.as_ref().and_then(|r| r.main_revision.clone());
|
||||
meta.core.version = report.banner_revision.clone();
|
||||
meta.database.port = Some(report.ports.mysql);
|
||||
meta.database.schemas = report.database_schemas.clone();
|
||||
for (rel, exe) in [("Core/worldserver.exe", &report.worldserver), ("Core/authserver.exe", &report.authserver)] {
|
||||
if let Some(e) = exe {
|
||||
meta.original_hashes.insert(rel.into(), e.sha256.clone());
|
||||
}
|
||||
}
|
||||
let server = PathBuf::from(&report.path);
|
||||
let dir = metadata_dir_for(&server)?;
|
||||
let (meta, _dir) = if dir.join("install.json").is_file() {
|
||||
MetaDir::open(&dir).map(|(d, m)| (m, d))?
|
||||
} else {
|
||||
let d = MetaDir::create(&server, &meta)?;
|
||||
(meta, d)
|
||||
};
|
||||
state.registry.register(&meta.id, &server)?;
|
||||
tracing::info!(id = %meta.id, path = %server.display(), "server imported (read-only)");
|
||||
Ok(summary(meta.id, server))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_servers(state: State<'_, AppState>) -> std::result::Result<Vec<ServerSummary>, UiError> {
|
||||
Ok(state.registry.list()?.into_iter().map(|(id, p)| summary(id, p)).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn forget_server(state: State<'_, AppState>, id: String) -> std::result::Result<(), UiError> {
|
||||
// Removes only the registry entry; no files are deleted.
|
||||
Ok(state.registry.unregister(&id)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn server_status(state: State<'_, AppState>, id: String) -> std::result::Result<StatusView, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let busy = state.busy.lock().map(|b| b.contains(&id)).unwrap_or(false);
|
||||
let path_exists = root.is_dir();
|
||||
let observed = tauri::async_runtime::spawn_blocking(move || {
|
||||
let ports = layout::read_ports(&root);
|
||||
process::observe(&root, &ports)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
Ok(StatusView { observed, busy, path_exists })
|
||||
}
|
||||
|
||||
async fn run_verb(state: &AppState, id: String, verb: Verb) -> std::result::Result<DriverOutcome, UiError> {
|
||||
let root = path_of(state, &id)?;
|
||||
if !state.busy.lock().map_err(|_| Error::Invalid("state poisoned".into()))?.insert(id.clone()) {
|
||||
return Ok(DriverOutcome {
|
||||
ok: false,
|
||||
exit_code: None,
|
||||
code: Some(coa_core::ErrorCode::OperationInProgress),
|
||||
human: Some(coa_core::ErrorCode::OperationInProgress.human()),
|
||||
output: String::new(),
|
||||
});
|
||||
}
|
||||
let result = tauri::async_runtime::spawn_blocking(move || driver::run(&root, verb)).await;
|
||||
if let Ok(mut b) = state.busy.lock() {
|
||||
b.remove(&id);
|
||||
}
|
||||
result.map_err(|e| Error::Invalid(e.to_string()))?.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_server(state: State<'_, AppState>, id: String) -> std::result::Result<DriverOutcome, UiError> {
|
||||
run_verb(&state, id, Verb::StartAll).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_server(state: State<'_, AppState>, id: String) -> std::result::Result<DriverOutcome, UiError> {
|
||||
run_verb(&state, id, Verb::StopAll).await
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let dir = data_dir();
|
||||
let _ = coa_core::logging::init(&dir.join("logs").join("manager.log"));
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()) })
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
default_install_dir,
|
||||
scan_server,
|
||||
add_server,
|
||||
list_servers,
|
||||
forget_server,
|
||||
server_status,
|
||||
start_server,
|
||||
stop_server
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CoA Server Manager");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
coa_server_manager_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CoA Server Manager",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.coa.servermanager",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "CoA Server Manager",
|
||||
"width": 1120,
|
||||
"height": 740,
|
||||
"minWidth": 900,
|
||||
"minHeight": 620,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type ServerSummary } from "@/lib/api";
|
||||
import { Welcome } from "@/screens/Welcome";
|
||||
import { ImportServer } from "@/screens/ImportServer";
|
||||
import { Shell } from "@/screens/Shell";
|
||||
|
||||
type View = "welcome" | "import";
|
||||
|
||||
export default function App() {
|
||||
const [servers, setServers] = useState<ServerSummary[] | null>(null);
|
||||
const [view, setView] = useState<View>("welcome");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const list = await api.list().catch(() => []);
|
||||
setServers(list);
|
||||
setActiveId((cur) => (cur && list.some((s) => s.id === cur) ? cur : (list[0]?.id ?? null)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
if (servers === null) return null;
|
||||
|
||||
if (servers.length > 0 && view === "welcome" && activeId) {
|
||||
return (
|
||||
<Shell
|
||||
servers={servers}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
onAddAnother={() => setView("import")}
|
||||
onForget={async (id) => {
|
||||
await api.forget(id);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "import") {
|
||||
return (
|
||||
<ImportServer
|
||||
canCancel={servers.length > 0}
|
||||
onCancel={() => setView("welcome")}
|
||||
onAdded={async (s) => {
|
||||
await refresh();
|
||||
setActiveId(s.id);
|
||||
setView("welcome");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Welcome onImport={() => setView("import")} />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-45 cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-gold text-[#17130a] hover:bg-gold-strong",
|
||||
secondary: "bg-card-2 text-ink border border-line hover:bg-[#1f2226]",
|
||||
ghost: "text-muted hover:text-ink hover:bg-white/5",
|
||||
danger: "bg-bad/90 text-white hover:bg-bad",
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
md: "h-11 px-5 text-[15px]",
|
||||
xl: "h-16 px-10 text-lg tracking-wide",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "secondary", size: "md" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-card border border-line bg-card/90 shadow-[0_8px_30px_rgb(0_0_0/0.28)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Dev-only: lets the UI run in a plain browser for visual review. Never bundled in production builds.
|
||||
export {};
|
||||
|
||||
(() => {
|
||||
let added = false, running = false;
|
||||
const report = {path:"C:\games\CoA-Repack",classification:"healthy",items:[
|
||||
{key:"worldserver",label:"World server",status:"found",detail:null},
|
||||
{key:"authserver",label:"Auth server",status:"found",detail:null},
|
||||
{key:"database_content",label:"Auth / Characters / World databases",status:"found",detail:"acore_auth, acore_characters, acore_world"},
|
||||
{key:"companions",label:"CoA Companions (bots)",status:"found",detail:"71 settings"},
|
||||
{key:"client",label:"Game client",status:"missing",detail:null}],
|
||||
worldserver:null,authserver:null,banner_revision:"3567e2f8e9d5",bot_config_keys:71,client:null,
|
||||
notes:["worldserver.exe differs from the one listed in RELEASE.json (release 2026-09-11); this looks like a customised build."],modifies_files:false};
|
||||
const svc=(n: string,p: number,st: string)=>({name:n,state:st,pid:st==="running"?100:null,port:p,port_ready:st==="running",conflict:null,uptime_secs:st==="running"?4206:null});
|
||||
(window as any).__TAURI_INTERNALS__ = {
|
||||
transformCallback: (cb: unknown)=>cb, unregisterCallback(){}, convertFileSrc:(x: unknown)=>x,
|
||||
invoke: async (cmd: string)=>{
|
||||
if(cmd==="list_servers") return added?[{id:"1",name:"CoA-Repack",path:report.path}]:[];
|
||||
if(cmd==="scan_server") return report;
|
||||
if(cmd==="add_server"){added=true;return {id:"1",name:"CoA-Repack",path:report.path};}
|
||||
if(cmd==="server_status"){const s=running?"running":"stopped";return {observed:{mysql:svc("mysql",3307,s),auth:svc("auth",3724,s),world:svc("world",8085,s)},busy:false,path_exists:true};}
|
||||
if(cmd==="start_server"){await new Promise((r: any)=>setTimeout(r,1500));running=true;return {ok:true,exit_code:0,code:null,human:null,output:""};}
|
||||
if(cmd==="stop_server"){await new Promise((r: any)=>setTimeout(r,1500));running=false;return {ok:true,exit_code:0,code:null,human:null,output:""};}
|
||||
if(cmd.startsWith("plugin:dialog")) return "C:\games\CoA-Repack";
|
||||
throw new Error("unmocked "+cmd);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,59 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg: #0e0f11;
|
||||
--color-card: #15171a;
|
||||
--color-card-2: #181a1e;
|
||||
--color-line: rgb(255 255 255 / 0.07);
|
||||
--color-ink: #e8e6e1;
|
||||
--color-muted: #9a9ca3;
|
||||
--color-gold: #c9a24a;
|
||||
--color-gold-strong: #dcb45a;
|
||||
--color-ok: #4fb383;
|
||||
--color-warn: #d9a441;
|
||||
--color-bad: #d9605a;
|
||||
--radius-card: 10px;
|
||||
--font-sans: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgb(201 162 74 / 0.35);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
pre,
|
||||
.selectable {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type Classification = "healthy" | "partial" | "unknown-custom" | "incompatible";
|
||||
export type ItemStatus = "found" | "missing" | "attention";
|
||||
|
||||
export interface ScanItem {
|
||||
key: string;
|
||||
label: string;
|
||||
status: ItemStatus;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface ExeInfo {
|
||||
size: number;
|
||||
sha256: string;
|
||||
matches_release: boolean | null;
|
||||
}
|
||||
|
||||
export interface ScanReport {
|
||||
path: string;
|
||||
classification: Classification;
|
||||
items: ScanItem[];
|
||||
worldserver: ExeInfo | null;
|
||||
authserver: ExeInfo | null;
|
||||
banner_revision: string | null;
|
||||
bot_config_keys: number;
|
||||
client: { path: string; executable: string } | null;
|
||||
notes: string[];
|
||||
modifies_files: boolean;
|
||||
}
|
||||
|
||||
export type ServiceState = "stopped" | "starting" | "running" | "stopping" | "crashed" | "updating" | "unknown";
|
||||
|
||||
export interface ServiceStatus {
|
||||
name: string;
|
||||
state: ServiceState;
|
||||
pid: number | null;
|
||||
port: number;
|
||||
port_ready: boolean;
|
||||
conflict: { port: number; pid: number; exe: string | null } | null;
|
||||
uptime_secs: number | null;
|
||||
}
|
||||
|
||||
export interface StatusView {
|
||||
observed: { mysql: ServiceStatus; auth: ServiceStatus; world: ServiceStatus };
|
||||
busy: boolean;
|
||||
path_exists: boolean;
|
||||
}
|
||||
|
||||
export interface Human {
|
||||
code: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export interface DriverOutcome {
|
||||
ok: boolean;
|
||||
exit_code: number | null;
|
||||
code: string | null;
|
||||
human: Human | null;
|
||||
output: string;
|
||||
}
|
||||
|
||||
export interface ServerSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface UiError {
|
||||
human: Human;
|
||||
technical: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
defaultInstallDir: () => invoke<string>("default_install_dir"),
|
||||
scan: (path: string) => invoke<ScanReport>("scan_server", { path }),
|
||||
add: (path: string) => invoke<ServerSummary>("add_server", { path }),
|
||||
list: () => invoke<ServerSummary[]>("list_servers"),
|
||||
forget: (id: string) => invoke<void>("forget_server", { id }),
|
||||
status: (id: string) => invoke<StatusView>("server_status", { id }),
|
||||
start: (id: string) => invoke<DriverOutcome>("start_server", { id }),
|
||||
stop: (id: string) => invoke<DriverOutcome>("stop_server", { id }),
|
||||
};
|
||||
|
||||
export function asUiError(e: unknown): UiError {
|
||||
if (e && typeof e === "object" && "human" in e) return e as UiError;
|
||||
return {
|
||||
human: { code: "unknown", title: "Something went wrong", message: "See the technical details.", actions: ["show_details"] },
|
||||
technical: String(e),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatUptime(secs: number | null | undefined): string {
|
||||
if (secs == null) return "—";
|
||||
const d = Math.floor(secs / 86400);
|
||||
const h = Math.floor((secs % 86400) / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m ${secs % 60}s`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
if (import.meta.env.DEV && !("__TAURI_INTERNALS__" in window)) {
|
||||
await import("./dev/mock");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { AlertTriangle, Check, Minus } from "lucide-react";
|
||||
import { api, asUiError, type Classification, type ScanReport, type ServerSummary, type UiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
const HEADLINE: Record<Classification, { title: string; tone: string; text: string }> = {
|
||||
healthy: { title: "Existing server detected", tone: "text-ok", text: "Everything needed to run this server was found." },
|
||||
partial: {
|
||||
title: "Server partly found",
|
||||
tone: "text-warn",
|
||||
text: "Some parts are missing. You can still add it; nothing will be fixed automatically.",
|
||||
},
|
||||
"unknown-custom": {
|
||||
title: "Custom server detected",
|
||||
tone: "text-warn",
|
||||
text: "This build is not recognised. Some update and install features will be disabled.",
|
||||
},
|
||||
incompatible: {
|
||||
title: "This is not a server folder",
|
||||
tone: "text-bad",
|
||||
text: "Choose the folder that contains Core, Data and mysql (or your worldserver.exe).",
|
||||
},
|
||||
};
|
||||
|
||||
export function ImportServer(props: {
|
||||
canCancel: boolean;
|
||||
onCancel: () => void;
|
||||
onAdded: (s: ServerSummary) => void | Promise<void>;
|
||||
}) {
|
||||
const [report, setReport] = useState<ScanReport | null>(null);
|
||||
const [error, setError] = useState<UiError | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function choose() {
|
||||
setError(null);
|
||||
const picked = await open({ directory: true, multiple: false, title: "Choose your server folder" });
|
||||
if (typeof picked !== "string") return;
|
||||
setBusy(true);
|
||||
setReport(null);
|
||||
try {
|
||||
setReport(await api.scan(picked));
|
||||
} catch (e) {
|
||||
setError(asUiError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!report) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onAdded(await api.add(report.path));
|
||||
} catch (e) {
|
||||
setError(asUiError(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const head = report ? HEADLINE[report.classification] : null;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex h-full max-w-2xl flex-col justify-center gap-5 px-8 py-10">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Add an existing server</h1>
|
||||
<p className="mt-1 text-muted">Pick the folder your server runs from. It will only be read.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={choose} disabled={busy}>
|
||||
{report ? "Choose another folder" : "Choose folder…"}
|
||||
</Button>
|
||||
{props.canCancel && (
|
||||
<Button variant="ghost" onClick={props.onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{busy && !report && <p className="text-muted">Looking at your server…</p>}
|
||||
|
||||
{error && (
|
||||
<Card className="border-bad/40 p-4" role="alert">
|
||||
<p className="font-medium text-bad">{error.human.title}</p>
|
||||
<p className="mt-1 text-sm text-muted">{error.human.message}</p>
|
||||
<details className="mt-2 text-xs text-muted">
|
||||
<summary className="cursor-pointer">Show technical details</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap">{error.technical}</pre>
|
||||
</details>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && head && (
|
||||
<Card className="p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
{report.classification === "healthy" ? (
|
||||
<Check className={`mt-0.5 h-5 w-5 ${head.tone}`} aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className={`mt-0.5 h-5 w-5 ${head.tone}`} aria-hidden />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="font-semibold">{head.title}</h2>
|
||||
<p className="text-sm text-muted">{head.text}</p>
|
||||
<p className="selectable mt-1 break-all text-xs text-muted">{report.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 divide-y divide-line text-sm">
|
||||
{report.items.map((it) => (
|
||||
<li key={it.key} className="flex items-center justify-between py-2">
|
||||
<span>{it.label}</span>
|
||||
<span className="flex items-center gap-2 text-muted">
|
||||
{it.detail && <span className="text-xs">{it.detail}</span>}
|
||||
{it.status === "found" ? (
|
||||
<span className="flex items-center gap-1 text-ok">
|
||||
<Check className="h-4 w-4" aria-hidden /> Detected
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Minus className="h-4 w-4" aria-hidden /> {it.key === "companions" ? "Not installed" : "Not found"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{report.notes.map((n) => (
|
||||
<p key={n} className="mt-3 text-sm text-warn">
|
||||
{n}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="mt-5 flex items-center justify-between border-t border-line pt-4">
|
||||
<p className="text-sm text-muted">No files will be changed during import.</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={add}
|
||||
disabled={busy || report.classification === "incompatible"}
|
||||
>
|
||||
Add server
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { api, asUiError, type Human, type ServerSummary, type ServiceStatus, type StatusView } from "@/lib/api";
|
||||
import { cn, formatUptime } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
type Action = "starting" | "stopping" | "restarting" | null;
|
||||
type Failure = { human: Human; technical: string };
|
||||
|
||||
function Row({ label, s }: { label: string; s: ServiceStatus }) {
|
||||
const running = s.state === "running";
|
||||
const starting = s.state === "starting";
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<span>{label}</span>
|
||||
<span
|
||||
className={cn("flex items-center gap-2 text-sm", running ? "text-ok" : starting ? "text-warn" : "text-muted")}
|
||||
role="status"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
running ? "bg-ok" : starting ? "animate-[pulse-dot_1.2s_ease-in-out_infinite] bg-warn" : "bg-muted/50",
|
||||
)}
|
||||
/>
|
||||
{running ? "Running" : starting ? "Starting…" : "Stopped"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Overview({ server, onForget }: { server: ServerSummary; onForget: () => Promise<void> }) {
|
||||
const [status, setStatus] = useState<StatusView | null>(null);
|
||||
const [action, setAction] = useState<Action>(null);
|
||||
const [failure, setFailure] = useState<Failure | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const alive = useRef(true);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const s = await api.status(server.id);
|
||||
if (alive.current) setStatus(s);
|
||||
} catch {
|
||||
/* transient; next poll retries */
|
||||
}
|
||||
}, [server.id]);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void poll();
|
||||
const t = setInterval(poll, 2000);
|
||||
return () => {
|
||||
alive.current = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [poll]);
|
||||
|
||||
async function run(kind: Exclude<Action, null>) {
|
||||
setFailure(null);
|
||||
setShowDetails(false);
|
||||
setAction(kind);
|
||||
try {
|
||||
if (kind !== "starting") {
|
||||
const out = await api.stop(server.id);
|
||||
if (!out.ok) throw { human: out.human, technical: out.output };
|
||||
}
|
||||
if (kind !== "stopping") {
|
||||
setAction("starting");
|
||||
const out = await api.start(server.id);
|
||||
if (!out.ok) throw { human: out.human, technical: out.output };
|
||||
}
|
||||
} catch (e) {
|
||||
const ui = asUiError(e);
|
||||
setFailure({ human: ui.human ?? asUiError(String(e)).human, technical: ui.technical });
|
||||
} finally {
|
||||
setAction(null);
|
||||
void poll();
|
||||
}
|
||||
}
|
||||
|
||||
if (!status) return <p className="text-muted">Checking your server…</p>;
|
||||
if (!status.path_exists) {
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h1 className="text-2xl font-semibold">Server folder not found</h1>
|
||||
<p className="mt-2 text-muted">{server.path} is missing or was moved.</p>
|
||||
<Button className="mt-4" onClick={() => void onForget()}>
|
||||
Remove from Manager (no files are deleted)
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { mysql, auth, world } = status.observed;
|
||||
const all = [mysql, auth, world];
|
||||
const running = all.every((s) => s.state === "running");
|
||||
const anyUp = all.some((s) => s.state !== "stopped");
|
||||
const transitioning = action !== null || status.busy;
|
||||
const conflict = all.find((s) => s.conflict)?.conflict ?? null;
|
||||
|
||||
const headline = transitioning
|
||||
? action === "stopping"
|
||||
? "Stopping…"
|
||||
: "Starting…"
|
||||
: running
|
||||
? "Online"
|
||||
: anyUp
|
||||
? "Needs attention"
|
||||
: "Offline";
|
||||
const tone = transitioning ? "text-warn" : running ? "text-ok" : anyUp ? "text-warn" : "text-muted";
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-2xl font-semibold">{server.name}</h1>
|
||||
<p className="selectable mt-0.5 text-xs text-muted">{server.path}</p>
|
||||
|
||||
<Card className="mt-6 p-7">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-muted">Server</div>
|
||||
<div className={cn("flex items-center gap-3 text-3xl font-semibold", tone)} role="status" aria-live="polite">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("h-3 w-3 rounded-full", running && !transitioning ? "bg-ok" : transitioning || anyUp ? "bg-warn" : "bg-muted/50")}
|
||||
/>
|
||||
{headline}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 divide-y divide-line border-y border-line">
|
||||
<Row label="Database" s={mysql} />
|
||||
<Row label="Auth server" s={auth} />
|
||||
<Row label="World server" s={world} />
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted">Uptime</dt>
|
||||
<dd className="mt-0.5 text-lg">{world.state === "running" ? formatUptime(world.uptime_secs) : "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted">Players online</dt>
|
||||
<dd className="mt-0.5 text-lg text-muted">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-7 flex items-center gap-4">
|
||||
{running ? (
|
||||
<Button variant="secondary" size="xl" disabled={transitioning} onClick={() => run("stopping")} className="min-w-56">
|
||||
{transitioning && <Loader2 className="h-5 w-5 animate-spin" aria-hidden />}
|
||||
STOP SERVER
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" size="xl" disabled={transitioning} onClick={() => run("starting")} className="min-w-56">
|
||||
{transitioning && <Loader2 className="h-5 w-5 animate-spin" aria-hidden />}
|
||||
{transitioning ? (action === "stopping" ? "STOPPING…" : "STARTING…") : "START SERVER"}
|
||||
</Button>
|
||||
)}
|
||||
{running && (
|
||||
<Button variant="ghost" size="sm" disabled={transitioning} onClick={() => run("restarting")}>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{anyUp && !running && (
|
||||
<Button variant="ghost" size="sm" disabled={transitioning} onClick={() => run("stopping")}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{anyUp && !running && !transitioning && (
|
||||
<p className="mt-3 text-sm text-muted">Some parts are running. Press Start to bring up the rest, or Stop to shut everything down.</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{conflict && !anyUp && (
|
||||
<Card className="mt-4 border-warn/40 p-4" role="alert">
|
||||
<p className="font-medium text-warn">A port this server needs is already in use</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Port {conflict.port} is used by another program{conflict.exe ? ` (${conflict.exe})` : ""}.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{failure && (
|
||||
<Card className="mt-4 border-bad/40 p-5" role="alert">
|
||||
<p className="font-medium text-bad">{failure.human.title}</p>
|
||||
<p className="mt-1 text-sm text-muted">{failure.human.message}</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={() => setShowDetails((v) => !v)}>
|
||||
{showDetails ? "Hide details" : "View details"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => void navigator.clipboard.writeText(failure.technical)}>
|
||||
Copy error
|
||||
</Button>
|
||||
</div>
|
||||
{showDetails && (
|
||||
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded bg-black/40 p-3 text-xs text-muted">
|
||||
{failure.technical || "(no output)"}
|
||||
</pre>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function Placeholder({ title, question }: { title: string; question: string }) {
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
<p className="mt-1 text-muted">{question}</p>
|
||||
<p className="mt-8 rounded-card border border-line bg-card/70 p-5 text-sm text-muted">
|
||||
This section is being built in a later release.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import { Archive, Bot, Gauge, Globe, Server, Settings, Terminal, Users, type LucideIcon } from "lucide-react";
|
||||
import { type ServerSummary } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Overview } from "@/screens/Overview";
|
||||
import { Placeholder } from "@/screens/Placeholder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Page = "overview" | "bots" | "server" | "players" | "friends" | "backups" | "console" | "settings";
|
||||
|
||||
const NAV: { id: Page; label: string; icon: LucideIcon; question: string }[] = [
|
||||
{ id: "overview", label: "Overview", icon: Gauge, question: "Is my server running?" },
|
||||
{ id: "bots", label: "Bots", icon: Bot, question: "How are my bots configured?" },
|
||||
{ id: "server", label: "Server", icon: Server, question: "How does my server behave?" },
|
||||
{ id: "players", label: "Players", icon: Users, question: "Who is playing right now?" },
|
||||
{ id: "friends", label: "Play with Friends", icon: Globe, question: "How can my friend join?" },
|
||||
{ id: "backups", label: "Backups", icon: Archive, question: "Is my data safe?" },
|
||||
{ id: "console", label: "Console", icon: Terminal, question: "What is the server doing?" },
|
||||
{ id: "settings", label: "Settings", icon: Settings, question: "How does Manager behave?" },
|
||||
];
|
||||
|
||||
export function Shell(props: {
|
||||
servers: ServerSummary[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onAddAnother: () => void;
|
||||
onForget: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const [page, setPage] = useState<Page>("overview");
|
||||
const server = props.servers.find((s) => s.id === props.activeId)!;
|
||||
const current = NAV.find((n) => n.id === page)!;
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<nav className="flex w-60 shrink-0 flex-col border-r border-line bg-[#0b0c0e] p-3" aria-label="Main">
|
||||
<div className="px-3 pb-4 pt-2 text-sm font-semibold tracking-wide text-gold">CoA Server Manager</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{NAV.map(({ id, label, icon: Icon }) => (
|
||||
<li key={id}>
|
||||
<button
|
||||
onClick={() => setPage(id)}
|
||||
aria-current={page === id ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex h-10 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-[15px] transition-colors",
|
||||
page === id ? "bg-white/[0.07] text-ink" : "text-muted hover:bg-white/5 hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-[18px] w-[18px]", page === id && "text-gold")} aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto border-t border-line pt-3">
|
||||
{props.servers.length > 1 && (
|
||||
<select
|
||||
className="mb-2 w-full rounded-md border border-line bg-card px-2 py-2 text-sm"
|
||||
value={props.activeId}
|
||||
onChange={(e) => props.onSelect(e.target.value)}
|
||||
aria-label="Server"
|
||||
>
|
||||
{props.servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={props.onAddAnother}>
|
||||
Manage another server
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="h-full flex-1 overflow-y-auto px-10 py-8">
|
||||
{page === "overview" ? (
|
||||
<Overview key={server.id} server={server} onForget={() => props.onForget(server.id)} />
|
||||
) : (
|
||||
<Placeholder title={current.label} question={current.question} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FolderSearch, Download } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export function Welcome({ onImport }: { onImport: () => void }) {
|
||||
return (
|
||||
<main className="flex h-full flex-col items-center justify-center px-8">
|
||||
<div className="mb-10 text-center">
|
||||
<div className="mx-auto mb-5 h-12 w-12 rounded-full border-2 border-gold/80 bg-card-2" aria-hidden />
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Welcome to CoA Server Manager</h1>
|
||||
<p className="mt-2 text-muted">Run your own Conquest of Azeroth server — no technical knowledge needed.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-3xl grid-cols-2 gap-5">
|
||||
<Card className="p-7 opacity-60" aria-disabled="true">
|
||||
<Download className="mb-4 h-7 w-7 text-gold" aria-hidden />
|
||||
<h2 className="text-lg font-semibold">Install new server</h2>
|
||||
<p className="mt-1 text-sm text-muted">One-click setup with everything included.</p>
|
||||
<p className="mt-4 inline-block rounded bg-white/5 px-2 py-1 text-xs text-muted">Coming in a later release</p>
|
||||
</Card>
|
||||
|
||||
<button
|
||||
onClick={onImport}
|
||||
className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left shadow-[0_8px_30px_rgb(0_0_0/0.28)] transition-colors hover:border-gold/60"
|
||||
>
|
||||
<FolderSearch className="mb-4 h-7 w-7 text-gold" aria-hidden />
|
||||
<h2 className="text-lg font-semibold">I already have a server</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Add your existing server. It is only read — nothing is changed.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,24 @@
|
||||
// Drives the real Tauri window over WebView2's DevTools protocol. Usage: node cdp.mjs <expression>
|
||||
const port = process.env.CDP_PORT ?? "9222";
|
||||
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
|
||||
const page = targets.find((t) => t.type === "page");
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
ws.onmessage = (m) => {
|
||||
const d = JSON.parse(m.data);
|
||||
if (d.id && pending.has(d.id)) pending.get(d.id)(d);
|
||||
};
|
||||
export function evaluate(expression) {
|
||||
return new Promise((resolve) => {
|
||||
const n = ++id;
|
||||
pending.set(n, resolve);
|
||||
ws.send(JSON.stringify({ id: n, method: "Runtime.evaluate", params: { expression, awaitPromise: true, returnByValue: true } }));
|
||||
});
|
||||
}
|
||||
if (process.argv[2]) {
|
||||
const r = await evaluate(process.argv[2]);
|
||||
console.log(JSON.stringify(r.result?.result?.value ?? r.result, null, 2));
|
||||
ws.close();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||
clearScreen: false,
|
||||
server: { port: 1420, strictPort: true },
|
||||
build: { target: "es2022", outDir: "dist" },
|
||||
});
|
||||