Refactor config loading and update SurrealDB setup documentation

- Refactored the `load` function in `config.rs` to use a new `locate_config_path` function for improved clarity and maintainability.
- Updated SurrealDB setup instructions in `SURREALDB_SETUP.md` and `surrealdb-setup.md` to reflect changes in the installation process and removed outdated script references.
- Added new dependencies for Tauri in `package.json` and `package-lock.json`, including `@tauri-apps/cli` and `@tauri-apps/plugin-dialog`.
- Updated the package-lock to include the latest versions of Tauri dependencies.
This commit is contained in:
Jacob Schmidt 2026-06-06 15:34:17 -05:00
parent a90d949b24
commit fe8f5dbef5
100 changed files with 10778 additions and 372 deletions

View File

@ -1,6 +1,7 @@
[workspace]
members = [
"arma/server/extension",
"bin/host/src-tauri",
"bin/icom",
"lib/models",
"lib/repositories",

View File

@ -28,6 +28,7 @@ arma/
client/ Client-side addons and browser UIs
server/ Server-side addons and extension crate
bin/
host/ Tauri host control panel for SurrealDB, ICOM, and Arma server
icom/ Interprocess communication helper
lib/
models/ Shared domain models
@ -42,6 +43,7 @@ tools/ Web UI build tooling
```powershell
cargo test
npm run build:webui
npm run host:dev
.\build-arma.ps1
```
@ -64,3 +66,27 @@ clients, and `@forge_server` on the server only.
```
Both commands report the persistence connection state.
## Host Control Panel
`bin/host` contains a Tauri control app for local Forge server hosting. It can
start and stop SurrealDB, `forge-icom.exe`, and an Arma 3 dedicated server,
edit their launch commands, and show basic TCP health plus captured logs.
```powershell
# Build ICOM first if the host config points at target/release/forge-icom.exe
cargo build --release -p forge-icom
# Run the host UI during development
npm run host:dev
# Build the packaged desktop app
npm run host:build
```
The app reads the shared `config.toml` from the repo root during development, or
from the current/executable directory in packaged use. If no shared config exists,
it falls back to `bin/host/host.example.toml`; saving from the Settings view writes
the active shared `config.toml`. The shared file includes the host process sections
plus the `[server]` section used by ICOM and the `[surreal]` section used by the
Arma extension.

View File

@ -41,14 +41,7 @@ impl Default for SurrealConfig {
pub fn load() -> Config {
CONFIG_CACHE
.get_or_init(|| {
let config_path = std::env::current_exe()
.ok()
.and_then(|exe| {
exe.parent()
.map(|dir| dir.join("@forge_server").join("config.toml"))
})
.filter(|path| path.exists())
.unwrap_or_else(|| PathBuf::from("@forge_server/config.toml"));
let config_path = locate_config_path();
match fs::read_to_string(&config_path) {
Ok(contents) => {
@ -77,3 +70,24 @@ pub fn load() -> Config {
})
.clone()
}
fn locate_config_path() -> PathBuf {
let mut candidates = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("@forge_server").join("config.toml"));
candidates.push(cwd.join("config.toml"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.join("@forge_server").join("config.toml"));
candidates.push(dir.join("config.toml"));
}
}
candidates
.into_iter()
.find(|path| path.exists())
.unwrap_or_else(|| PathBuf::from("@forge_server/config.toml"))
}

View File

@ -1,8 +0,0 @@
@echo off
setlocal EnableExtensions
set "FORGE_SURREALDB_VERSION=%~1"
if not defined FORGE_SURREALDB_VERSION set "FORGE_SURREALDB_VERSION=3"
call "%~dp0UpdateMe.bat" "%FORGE_SURREALDB_VERSION%"
if errorlevel 1 exit /b %errorlevel%
call "%~dp0RunMe.bat"

View File

@ -1,100 +1,23 @@
# Forge SurrealDB
Forge uses SurrealDB as the durable database for the server extension. These
helpers install the SurrealDB CLI and start a local RocksDB-backed Forge
database from this directory.
Forge uses SurrealDB as the durable database for the server extension. The
Forge Host app installs, updates, starts, and stops the local SurrealDB process.
These scripts are for local development and single-host Forge servers. For a
public or shared production host, change the root password and review bind,
firewall, TLS, backup, and upgrade policy before exposing the database.
Use the SurrealDB view in Forge Host to:
## Windows
- Install or update the SurrealDB CLI.
- Start the local RocksDB-backed database.
- Configure the database bind address, root credentials, and database path.
Install or update SurrealDB to the newest compatible SurrealDB 3.x release:
```bat
UpdateMe.bat
```
Install a specific SurrealDB release:
```bat
UpdateMe.bat v3.1.2
```
Install the latest stable SurrealDB release, including newer major versions:
```bat
UpdateMe.bat latest
```
`latest` requires confirmation because a newer SurrealDB major version can
require rebuilding the Forge server extension from source with a compatible
`surrealdb` Rust crate.
The PowerShell entry point exposes the same behavior:
```powershell
.\UpdateSurrealDB.ps1
.\UpdateSurrealDB.ps1 -Version v3.1.2
.\UpdateSurrealDB.ps1 -Version latest
```
If this is the first install and the terminal cannot find `surreal` after the
script finishes, open a new terminal so Windows reloads `PATH`.
Start Forge's local database:
```bat
RunMe.bat
```
Or start it directly with PowerShell:
```powershell
.\RunSurrealDB.ps1
```
Install and start in one step:
```bat
AllInOne.bat
```
`AllInOne.bat` also defaults to the newest compatible SurrealDB 3.x release.
Pass the same version argument as `UpdateMe.bat` to override it.
## Linux or macOS
Install SurrealDB:
```bash
./setup.sh
```
Start Forge's local database:
```bash
./run.sh
```
Update SurrealDB:
```bash
./update.sh
```
## Manual Command
The run scripts execute:
The default local launch arguments are:
```bash
surreal start --user root --pass root --bind 127.0.0.1:8000 rocksdb://forge.db
```
The database files are created under `arma/server/surrealdb/forge.db`.
The default database files are created under this directory as `forge.db`.
Forge's extension config should match the local SurrealDB server:
Forge's shared `config.toml` should match the local SurrealDB server:
```toml
[surreal]
@ -105,3 +28,6 @@ username = "root"
password = "root"
connect_timeout_ms = 5000
```
`root`/`root` is only the local development default. For a public or shared
server, set a real password and keep `config.toml` aligned.

View File

@ -1,2 +0,0 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0RunSurrealDB.ps1"

View File

@ -1,16 +0,0 @@
param(
[string]$User = "root",
[string]$Pass = "root",
[string]$Bind = "127.0.0.1:8000",
[string]$DatabasePath = "forge.db"
)
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
if (-not (Get-Command surreal -ErrorAction SilentlyContinue)) {
throw "The 'surreal' command was not found. Run UpdateSurrealDB.ps1 first, then open a new terminal if PATH was updated."
}
surreal start --user $User --pass $Pass --bind $Bind "rocksdb://$DatabasePath"

View File

@ -1,10 +0,0 @@
@echo off
setlocal EnableExtensions
set "DEFAULT_SURREALDB_VERSION=3"
set "TARGET_SURREALDB_VERSION=%~1"
if not defined TARGET_SURREALDB_VERSION set "TARGET_SURREALDB_VERSION=%FORGE_SURREALDB_VERSION%"
if not defined TARGET_SURREALDB_VERSION set "TARGET_SURREALDB_VERSION=%DEFAULT_SURREALDB_VERSION%"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0UpdateSurrealDB.ps1" -Version "%TARGET_SURREALDB_VERSION%"
exit /b %errorlevel%

View File

@ -1,120 +0,0 @@
param(
[string]$Version = "3",
[switch]$Force
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$VersionUrl = "https://version.surrealdb.com"
$DownloadBaseUrl = "https://download.surrealdb.com"
$Architecture = "windows-amd64"
function Normalize-Version {
param([string]$Value)
$trimmed = $Value.Trim()
if ($trimmed -match "(?i)^latest$") {
return "latest"
}
if ($trimmed -match "^v?\d+$") {
return $trimmed.TrimStart("v")
}
if ($trimmed -match "^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$") {
return "v$($trimmed.TrimStart("v"))"
}
throw "Unsupported SurrealDB version '$Value'. Use a major version like '3', an exact version like 'v3.1.2', or 'latest'."
}
function Get-Latest-Version {
return (Invoke-WebRequest $VersionUrl -UseBasicParsing).Content.Trim()
}
function Resolve-Version {
param([string]$Target)
$normalized = Normalize-Version $Target
if ($normalized -eq "latest") {
return Get-Latest-Version
}
if ($normalized -match "^\d+$") {
$latest = Get-Latest-Version
if ($latest -notmatch "^v?$normalized\.") {
throw "Latest SurrealDB is $latest, not $normalized.x. Pass an exact $normalized.x version or use 'latest' after confirming Forge compatibility."
}
return $latest
}
return $normalized
}
function Confirm-Latest {
if ($Force) {
return
}
Write-Host ""
Write-Host "WARNING: This will install the latest stable SurrealDB release, even if it is newer"
Write-Host "than the Forge server extension was compiled and tested against."
Write-Host ""
Write-Host "The Forge server extension currently targets SurrealDB 3.x. A newer major"
Write-Host "SurrealDB release can require rebuilding Forge from source with a compatible"
Write-Host "surrealdb Rust crate before the extension works correctly."
Write-Host ""
$answer = Read-Host "Install latest SurrealDB anyway? [Y/N]"
if ($answer -notmatch "^(?i)y(es)?$") {
exit 1
}
}
function Get-Install-Path {
$existing = Get-Command surreal -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $existing -and $existing.Source -and (Split-Path -Leaf $existing.Source) -ieq "surreal.exe") {
return $existing.Source
}
$installDirectory = Join-Path $env:LOCALAPPDATA "SurrealDB"
New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
return Join-Path $installDirectory "surreal.exe"
}
function Ensure-User-Path {
param([string]$Directory)
$pathParts = $env:Path -split ";" | Where-Object { $_ }
if ($pathParts -notcontains $Directory) {
$env:Path = "$Directory;$env:Path"
}
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$userPathParts = $userPath -split ";" | Where-Object { $_ }
if ($userPathParts -notcontains $Directory) {
$newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) { $Directory } else { "$Directory;$userPath" }
[Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
Write-Host "Added $Directory to the user PATH. Open a new terminal if 'surreal' is not found later."
}
}
$normalizedTarget = Normalize-Version $Version
if ($normalizedTarget -eq "latest") {
Confirm-Latest
}
$resolvedVersion = Resolve-Version $Version
$installPath = Get-Install-Path
$installDirectory = Split-Path -Parent $installPath
New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
$downloadUrl = "$DownloadBaseUrl/$resolvedVersion/surreal-$resolvedVersion.$Architecture.exe"
$tempPath = Join-Path ([System.IO.Path]::GetTempPath()) "surreal-$resolvedVersion.$Architecture.exe"
Write-Host "Installing SurrealDB $resolvedVersion from $downloadUrl"
Invoke-WebRequest $downloadUrl -OutFile $tempPath -UseBasicParsing
Move-Item -Force -Path $tempPath -Destination $installPath
Ensure-User-Path $installDirectory
& $installPath version

View File

@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
surreal start --user root --pass root --bind 127.0.0.1:8000 rocksdb://forge.db

View File

@ -1,15 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if command -v surreal >/dev/null 2>&1; then
surreal version
exit 0
fi
if command -v brew >/dev/null 2>&1; then
brew install surrealdb/tap/surreal
else
curl -sSf https://install.surrealdb.com | sh
fi
surreal version

View File

@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if command -v brew >/dev/null 2>&1; then
brew upgrade surrealdb/tap/surreal || brew install surrealdb/tap/surreal
else
curl -sSf https://install.surrealdb.com | sh
fi
surreal version

View File

@ -0,0 +1,17 @@
// Basic Network Configuration
language = "English";
adapter = -1;
3D_Performance = 1;
Resolution_W = 0;
Resolution_H = 0;
Resolution_Bpp = 32;
MinBandwidth = 131072;
MaxBandwidth = 10000000000;
MaxMsgSend = 128;
MaxSizeGuaranteed = 512;
MaxSizeNonguaranteed = 256;
MinErrorToSend = 0.001;
MinErrorToSendNear = 0.01;
MaxCustomFileSize = 0;
Windowed = 0;

View File

@ -0,0 +1,35 @@
[server]
host = "0.0.0.0"
port = 9090
[surreal]
endpoint = "127.0.0.1:8000"
namespace = "forge"
database = "main"
username = "root"
password = "root"
connect_timeout_ms = 5000
[surrealdb]
enabled = true
command = "surreal"
args = ["start", "--user", "root", "--pass", "root", "--bind", "127.0.0.1:8000", "rocksdb://forge.db"]
working_dir = "../../arma/server/surrealdb"
health_host = "127.0.0.1"
health_port = 8000
[icom]
enabled = true
command = "target/release/forge-icom.exe"
args = []
working_dir = "../.."
health_host = "127.0.0.1"
health_port = 9090
[arma]
enabled = false
command = "arma3server_x64.exe"
args = ["-port=2302", "-profiles=serverprofiles", "-name=server", "-noBattlEye"]
working_dir = ""
health_host = "127.0.0.1"
health_port = 2302

View File

@ -0,0 +1,81 @@
// server.cfg
// GLOBAL SETTINGS
hostname = "Fun and Test Server"; // The name of the server that shall be displayed in the public server list
password = ""; // Password for joining, eg connecting to the server
passwordAdmin = "xyz"; // Password to become server admin. When in Arma MP and connected to the server, type '#login xyz'
serverCommandPassword = "xyzxyz"; // Password required by alternate syntax of [[serverCommand]] server-side scripting.
logFile = "server_console.log"; // Where the logfile should go and what it should be called
// WELCOME MESSAGE ("Message Of The Day")
// It can be several lines, separated by comma
// Empty messages "" will not be displayed and are only here to add delay
motd[] =
{
"", "",
"Two empty lines above to increase the time interval",
"Welcome to our server",
"", "",
"We are looking for fun - Join us Now!",
"http://www.example.com",
"One more empty line below to increase interval",
""
};
motdInterval = 2.5; // Time interval (in seconds) between each message
// JOINING RULES
maxPlayers = 64; // Maximum amount of players. Civilians and watchers, beholder, bystanders and so on also count as player.
kickDuplicate = 1; // Each ArmA version has its own ID. If kickDuplicate is set to 1, a player will be kicked when he joins a server where another player with the same ID is playing.
verifySignatures = 2; // Verifies .pbos against .bisign files. Valid values 0 (disabled), 1 (prefer v2 sigs but accept v1 too) and 2 (only v2 sigs are allowed).
equalModRequired = 0; // Outdated. If set to 1, player has to use exactly the same -mod= startup parameter as the server.
allowedFilePatching = 0; // Allow or prevent client using -filePatching to join the server. 0, is disallow, 1 is allow HC, 2 is allow all clients (since Arma 3 v1.50)
filePatchingExceptions[] = { "123456789", "987654321" }; // Whitelisted Steam IDs allowed to join with -filePatching enabled
// requiredBuild = 12345; // Require clients joining to have at least build 12345 of game, preventing obsolete clients to connect
// VOTING
voteMissionPlayers = 1; // Tells the server how many people must connect so that it displays the mission selection screen.
voteThreshold = 0.33; // 33% or more players need to vote for something, for example an admin or a new map, to become effective
// INGAME SETTINGS
disableVoN = 0; // If set to 1, Voice over Net will not be available
vonCodec = 1; // If set to 1 then it uses IETF standard OPUS codec, if to 0 then it uses SPEEX codec (since Arma 3 v1.58)
vonCodecQuality = 30; // 0..10 = 8kHz, 11..20 = 16kHz, 21..30 = 32kHz (48kHz)
persistent = 1; // If 1, missions still run on even after the last player disconnected.
timeStampFormat = "short"; // Set the timestamp format used on each report line in server-side RPT file. Possible values are "none" (default), "short", "full".
BattlEye = 1; // Server to use BattlEye system
allowedLoadFileExtensions[] = { "hpp", "sqs", "sqf", "fsm", "cpp", "paa", "txt", "xml", "inc", "ext", "sqm", "ods", "fxy", "lip", "csv", "kb", "bik", "bikb", "html", "htm", "biedi" }; // only allow files with those extensions to be loaded via loadFile command (since Arma 3 build 1.19.124216)
allowedPreprocessFileExtensions[] = { "hpp", "sqs", "sqf", "fsm", "cpp", "paa", "txt", "xml", "inc", "ext", "sqm", "ods", "fxy", "lip", "csv", "kb", "bik", "bikb", "html", "htm", "biedi" }; // only allow files with those extensions to be loaded via preprocessFile/preprocessFileLineNumber commands (since Arma 3 build 1.19.124323)
allowedHTMLLoadExtensions[] = { "htm", "html", "xml", "txt" }; // only allow files with those extensions to be loaded via HTMLLoad command (since Arma 3 build 1.27.126715)
// allowedHTMLLoadURIs[] = {}; // Leave commented to let missions/campaigns/addons decide what URIs are supported. Uncomment to define server-level restrictions for URIs
// TIMEOUTS
disconnectTimeout = 5; // Time to wait before disconnecting a user which temporarly lost connection. Range is 5 to 90 seconds.
maxDesync = 150; // Max desync value until server kick the user
maxPing= 200; // Max ping value until server kick the user
maxPacketLoss = 50; // Max packetloss value until server kick the user
kickClientsOnSlowNetwork[] = { 0, 0, 0, 0 }; // Defines if {<MaxPing>, <MaxPacketLoss>, <MaxDesync>, <DisconnectTimeout>} will be logged (0) or kicked (1)
kickTimeout[] = { { 0, -1 }, { 1, 180 }, { 2, 180 }, { 3, 180 } };
votingTimeOut[] = { 60, 90 }; // Kicks users from server if they spend too much time in mission voting
roleTimeOut[] = { 90, 120 }; // Kicks users from server if they spend too much time in role selection
briefingTimeOut[] = { 60, 90 }; // Kicks users from server if they spend too much time in briefing (map) screen
debriefingTimeOut[] = { 45, 60 }; // Kicks users from server if they spend too much time in debriefing screen
lobbyIdleTimeout = 300; // The amount of time the server will wait before force-starting a mission without a logged-in Admin.
// SCRIPTING ISSUES
onUserConnected = "";
onUserDisconnected = "";
doubleIdDetected = "";
// SIGNATURE VERIFICATION
onUnsignedData = "kick (_this select 0)"; // unsigned data detected
onHackedData = "kick (_this select 0)"; // tampering of the signature detected
onDifferentData = ""; // data with a valid signature, but different version than the one present on server detected
// MISSIONS CYCLE (see below)
randomMissionOrder = true; // Randomly iterate through Missions list
autoSelectMission = true; // Server auto selects next mission in cycle
class Missions {}; // An empty Missions class means there will be no mission rotation
missionWhitelist[] = {}; // An empty whitelist means there is no restriction on what missions' available

View File

@ -0,0 +1,24 @@
[package]
name = "forge-host"
version = "0.1.0"
edition = "2024"
build = "build.rs"
[[bin]]
name = "forge-host"
path = "src/main.rs"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { version = "0.12.20", default-features = false, features = ["rustls-tls"] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
toml = "0.9.8"
[target.'cfg(not(windows))'.dependencies]
flate2 = "1.1.2"
tar = "0.4.44"
[build-dependencies]
tauri-build = { version = "2", features = [] }

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for Forge Host",
"windows": ["main"],
"permissions": ["core:default", "dialog:default"]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default permissions for Forge Host","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Forge Host",
"version": "0.1.0",
"identifier": "com.idsolutions.forge.host",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Forge Host",
"width": 1280,
"height": 720,
"minWidth": 980,
"minHeight": 640
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

946
bin/host/src/app.js Normal file
View File

@ -0,0 +1,946 @@
const serviceNames = {
surrealdb: "SurrealDB",
icom: "ICOM",
arma: "Arma 3 Server",
};
const subtitles = {
overview: "Start, stop, and check local Forge hosting services.",
settings: "Edit process command, arguments, working directory, and health port.",
logs: "Recent stdout, stderr, and host supervisor events.",
};
let snapshot = null;
let refreshTimer = null;
let activeSettingsService = "surrealdb";
let configDirty = false;
let activeEditorPath = "";
let surrealInstallInfo = null;
let selectedArmaConfigTemplate = "server";
const views = {
overview: document.getElementById("overviewView"),
settings: document.getElementById("settingsView"),
logs: document.getElementById("logsView"),
};
document.querySelectorAll(".nav-button").forEach((button) => {
button.addEventListener("click", () => {
const view = button.dataset.view;
if (button.dataset.service) {
if (configDirty && button.dataset.service !== activeSettingsService) {
persistActiveSettingsForm();
}
activeSettingsService = button.dataset.service;
} else if (view === "settings") {
if (configDirty && activeSettingsService !== "surrealdb") {
persistActiveSettingsForm();
}
activeSettingsService = "surrealdb";
}
document.querySelectorAll(".nav-button").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
Object.entries(views).forEach(([key, element]) => element.classList.toggle("active", key === view));
setHeader(view);
renderSettings(true);
syncBulkActionsVisibility();
syncRefreshTimer();
});
});
document.getElementById("refreshButton").addEventListener("click", refresh);
document.getElementById("saveButton").addEventListener("click", saveConfig);
document.getElementById("startAllButton").addEventListener("click", startAll);
document.getElementById("stopAllButton").addEventListener("click", stopAll);
document.getElementById("editorCloseButton").addEventListener("click", closeConfigEditor);
document.getElementById("editorReloadButton").addEventListener("click", () => runAction(() => openConfigEditor(activeEditorPath)));
document.getElementById("editorSaveButton").addEventListener("click", () => runAction(saveConfigEditor));
async function refresh() {
if (isSettingsViewActive() && configDirty) {
return;
}
try {
const nextSnapshot = await tauriInvoke("get_snapshot");
if (configDirty && snapshot) {
persistActiveSettingsForm();
nextSnapshot.config = snapshot.config;
}
snapshot = nextSnapshot;
render();
} catch (error) {
renderError(error);
}
}
async function startService(name) {
snapshot = await tauriInvoke("start_service", { name });
render();
}
async function stopService(name) {
snapshot = await tauriInvoke("stop_service", { name });
render();
}
async function startAll() {
if (!snapshot) return;
const serviceNames = Object.keys(snapshot.config).filter(
(name) =>
snapshot.config[name].enabled && (name === "surrealdb" || snapshot.config[name].command.trim() !== "")
);
for (const name of serviceNames) {
const status = snapshot.statuses.find((s) => s.name === name);
if (status && !status.running) {
await startService(name);
if (name === "surrealdb") {
await waitForServiceHealth("surrealdb", 30000);
}
}
}
}
async function stopAll() {
if (!snapshot) return;
const serviceNames = Object.keys(snapshot.config).filter((name) => snapshot.config[name].enabled);
for (const name of serviceNames) {
const status = snapshot.statuses.find((s) => s.name === name);
if (status && status.running) {
await stopService(name);
}
}
}
async function waitForServiceHealth(serviceName, timeoutMs = 30000) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
await refresh();
const status = snapshot?.statuses.find((s) => s.name === serviceName);
if (status?.healthy) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return false;
}
async function saveConfig() {
if (!snapshot) {
await refresh();
}
const config = readFormConfig();
snapshot = await tauriInvoke("save_config", { config });
configDirty = false;
render();
}
function tauriInvoke(command, payload) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return Promise.reject("Tauri invoke API is not available in this window.");
}
return invoke(command, payload);
}
function render() {
if (!snapshot) return;
document.getElementById("configPath").textContent = snapshot.config_path;
syncSidebarStatus();
renderServices();
renderSettings();
renderLogs();
syncBulkActionsVisibility();
}
function setHeader(view) {
const title = view === "settings" ? serviceNames[activeSettingsService] : activeNavLabel();
document.getElementById("viewTitle").textContent = title;
document.getElementById("viewSubtitle").textContent = subtitles[view];
}
function activeNavLabel() {
return document.querySelector(".nav-button.active span:last-child")?.textContent.trim() || "Overview";
}
function syncSidebarStatus() {
document.querySelectorAll(".nav-button[data-service], .nav-button[data-view='settings']:not([data-service])").forEach((button) => {
const service = button.dataset.service || "surrealdb";
const config = snapshot.config[service];
const status = snapshot.statuses.find((s) => s.name === service);
const enabled = config?.enabled;
const running = status?.running;
const healthy = status?.healthy;
const isDisabled = !enabled;
const isOnline = enabled && running && healthy;
const isStopped = enabled && !isOnline;
button.classList.toggle("service-enabled", Boolean(isOnline));
button.classList.toggle("service-disabled", Boolean(isDisabled));
button.classList.toggle("service-stopped", Boolean(isStopped));
});
}
function renderServices() {
const grid = document.getElementById("serviceGrid");
const template = document.getElementById("serviceCardTemplate");
grid.replaceChildren();
snapshot.statuses.forEach((status) => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector("h2").textContent = serviceNames[status.name] || status.name;
const pingPill = node.querySelector(".ping-pill");
const pingDisplay = resolvePingDisplay(status);
pingPill.textContent = pingDisplay.label;
pingPill.classList.remove("good", "warn", "bad", "unavailable");
pingPill.classList.add(pingDisplay.className);
node.querySelector(".command-line").textContent = status.command || "No command configured";
const pill = node.querySelector(".status-pill");
pill.textContent = status.running ? "Running" : "Stopped";
pill.classList.add(status.running ? "running" : "stopped");
node.querySelector(".pid").textContent = status.pid ? String(status.pid) : "-";
node.querySelector(".health").textContent = status.health;
node.querySelector(".enabled").textContent = status.enabled ? "Yes" : "No";
const start = node.querySelector(".start");
const stop = node.querySelector(".stop");
if (!start || !stop) {
throw new Error("Service card template is missing start or stop controls.");
}
start.disabled = status.running || !status.enabled || !status.configured;
stop.disabled = !status.running;
start.addEventListener("click", () => runAction(() => startService(status.name)));
stop.addEventListener("click", () => runAction(() => stopService(status.name)));
grid.appendChild(node);
});
}
function resolvePingDisplay(status) {
if (!status.healthy || typeof status.ping_ms !== "number") {
return { label: "N/A", className: "unavailable" };
}
if (status.ping_ms <= 60) {
return { label: `${status.ping_ms} ms`, className: "good" };
}
if (status.ping_ms <= 120) {
return { label: `${status.ping_ms} ms`, className: "warn" };
}
return { label: `${status.ping_ms} ms`, className: "bad" };
}
function renderSettings(force = false) {
if (!snapshot) return;
if (!isSettingsViewActive()) return;
const form = document.getElementById("configForm");
if (!force && configDirty && isSettingsViewActive()) return;
if (!snapshot.config[activeSettingsService]) {
activeSettingsService = Object.keys(snapshot.config)[0];
}
const name = activeSettingsService;
const config = snapshot.config[name];
form.replaceChildren();
form.className = "settings-form";
form.innerHTML = renderSettingsPanel(name, config);
form.querySelectorAll("input[data-service], input[data-arg-key], select[data-arg-key]").forEach((input) => {
input.addEventListener("input", () => {
configDirty = true;
});
input.addEventListener("change", () => {
configDirty = true;
});
});
form.querySelectorAll("[data-picker]").forEach((button) => {
button.addEventListener("click", () => {
runAction(() => pickPath(button.dataset.picker, button.dataset.target, button.dataset.serviceTarget));
});
});
form.querySelectorAll("[data-create-server-config]").forEach((button) => {
button.addEventListener("click", () => {
const template = document.querySelector("[data-config-template]")?.value || "server";
runAction(() => createArmaServerConfig(template));
});
});
form.querySelectorAll("[data-edit-server-config]").forEach((button) => {
button.addEventListener("click", () => {
const path = document.querySelector('[data-arg-key="config"]')?.value.trim();
if (!path) {
alert("Select or create a server config first.");
return;
}
runAction(() => openConfigEditor(resolveArmaConfigPath(path)));
});
});
form.querySelectorAll("[data-surreal-install]").forEach((button) => {
button.addEventListener("click", () => {
const version = selectedSurrealInstallVersion();
runAction(() => installSurrealDb(version));
});
});
form.querySelectorAll("[data-surreal-version-mode]").forEach((select) => {
select.addEventListener("change", () => {
syncSurrealCustomVersion();
});
});
form.querySelectorAll("[data-config-template]").forEach((select) => {
select.addEventListener("change", () => {
selectedArmaConfigTemplate = select.value;
});
});
if (name === "surrealdb" && !surrealInstallInfo) {
loadSurrealInstallInfo();
} else if (name === "surrealdb" && surrealInstallInfo && !surrealInstallInfo.latest) {
loadLatestSurrealVersion();
}
}
function isSettingsViewActive() {
return views.settings.classList.contains("active");
}
function syncRefreshTimer() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
if (!isSettingsViewActive()) {
refreshTimer = setInterval(refresh, 2500);
}
}
function syncBulkActionsVisibility() {
const bulkActions = document.getElementById("bulkActions");
bulkActions.style.display = views.overview.classList.contains("active") ? "flex" : "none";
const stopAllButton = document.getElementById("stopAllButton");
const hasRunningService = Boolean(snapshot?.statuses.some((status) => status.running));
stopAllButton.disabled = !hasRunningService;
}
function renderLogs() {
const output = document.getElementById("logOutput");
output.textContent = snapshot.logs.length ? snapshot.logs.join("\n") : "No host logs yet.";
output.scrollTop = output.scrollHeight;
}
function renderError(error) {
const message = String(error);
document.getElementById("configPath").textContent = "Unable to load";
document.getElementById("serviceGrid").replaceChildren();
document.getElementById("configForm").replaceChildren();
document.getElementById("logOutput").textContent = message;
}
function readFormConfig() {
const config = structuredClone(snapshot.config);
document.querySelectorAll("[data-service][data-field]").forEach((input) => {
const service = input.dataset.service;
const field = input.dataset.field;
if (field === "enabled") {
config[service][field] = input.checked;
} else if (field === "health_port") {
config[service][field] = Number(input.value);
} else if (field === "args") {
config[service][field] = readArgumentFields(service, input);
} else {
config[service][field] = input.value.trim();
}
});
return config;
}
function renderSettingsPanel(name, config) {
if (name === "arma") {
return renderArmaSettings(config);
}
if (name === "icom") {
return renderIcomSettings(config);
}
return `
<section class="settings-panel active">
<div class="settings-header">
<h2>${serviceNames[name] || name}</h2>
<label class="toggle-row">
<input type="checkbox" data-service="${name}" data-field="enabled" ${config.enabled ? "checked" : ""} />
<span class="switch-track" aria-hidden="true"><span class="switch-thumb"></span></span>
<span>Enabled</span>
</label>
</div>
<div class="field-grid">
${renderSurrealInstallControls()}
<div class="field full">
<label>Working Directory</label>
<input data-service="${name}" data-field="working_dir" value="${escapeAttr(config.working_dir)}" />
</div>
<div class="field">
<label>Health Host</label>
<input data-service="${name}" data-field="health_host" value="${escapeAttr(config.health_host)}" />
</div>
<div class="field">
<label>Health Port</label>
<input data-service="${name}" data-field="health_port" type="number" min="1" max="65535" value="${config.health_port}" />
</div>
<div class="field full">
<label>Arguments</label>
<div class="args-list" data-service="${name}" data-field="args">
${renderArgumentFields(name, config.args)}
</div>
</div>
</div>
</section>
`;
}
function renderSurrealInstallControls() {
const installed = surrealInstallInfo?.installed;
const version = surrealInstallInfo?.version || "Not installed";
const latest = surrealInstallInfo?.latest || "Checking...";
const path = surrealInstallInfo?.path || "SurrealDB will be installed to the user-local default path.";
return `
<div class="field full surreal-install">
<div class="install-summary">
<div>
<label>SurrealDB Install</label>
<strong>${escapeHtml(version)}</strong>
<span>${escapeHtml(path)}</span>
</div>
<div>
<label>Latest</label>
<strong>${escapeHtml(latest)}</strong>
<span>${installed ? "Choose a target version when updating." : "Install before starting SurrealDB."}</span>
</div>
</div>
<div class="install-action">
<label class="install-version">
<span>Target</span>
<select data-surreal-version-mode>
<option value="3" selected>3.x</option>
<option value="2">2.x</option>
<option value="latest">Latest</option>
<option value="custom">Custom</option>
</select>
</label>
<label class="install-custom" hidden>
<span>Custom Version</span>
<input data-surreal-custom-version placeholder="v3.1.3" />
</label>
<button type="button" data-surreal-install>${installed ? "Update" : "Install"}</button>
</div>
</div>
`;
}
function selectedSurrealInstallVersion() {
const mode = document.querySelector("[data-surreal-version-mode]")?.value || "3";
if (mode !== "custom") {
return mode;
}
return document.querySelector("[data-surreal-custom-version]")?.value.trim() || "3";
}
function syncSurrealCustomVersion() {
const custom = document.querySelector(".install-custom");
if (!custom) return;
const isCustom = document.querySelector("[data-surreal-version-mode]")?.value === "custom";
custom.hidden = !isCustom;
if (isCustom) {
custom.querySelector("input")?.focus();
}
}
async function loadSurrealInstallInfo() {
try {
surrealInstallInfo = await tauriInvoke("get_surrealdb_install_info");
if (activeSettingsService === "surrealdb" && isSettingsViewActive() && !configDirty && !isSurrealInstallFocused()) {
renderSettings(true);
}
await loadLatestSurrealVersion();
} catch (error) {
surrealInstallInfo = {
installed: false,
version: "Unable to check",
path: String(error),
latest: "Unknown",
};
}
}
async function loadLatestSurrealVersion() {
try {
const latest = await tauriInvoke("get_latest_surrealdb_version");
surrealInstallInfo = { ...(surrealInstallInfo || {}), latest };
if (activeSettingsService === "surrealdb" && isSettingsViewActive() && !configDirty && !isSurrealInstallFocused()) {
renderSettings(true);
}
} catch (error) {
surrealInstallInfo = { ...(surrealInstallInfo || {}), latest: "Unavailable" };
if (activeSettingsService === "surrealdb" && isSettingsViewActive() && !configDirty && !isSurrealInstallFocused()) {
renderSettings(true);
}
}
}
function isSurrealInstallFocused() {
return Boolean(document.activeElement?.closest?.(".surreal-install"));
}
async function installSurrealDb(version) {
surrealInstallInfo = await tauriInvoke("install_surrealdb", { version });
await refresh();
if (activeSettingsService === "surrealdb" && isSettingsViewActive()) {
renderSettings(true);
}
}
function renderIcomSettings(config) {
return `
<section class="settings-panel active">
<div class="settings-header">
<h2>ICOM</h2>
<label class="toggle-row">
<input type="checkbox" data-service="icom" data-field="enabled" ${config.enabled ? "checked" : ""} />
<span class="switch-track" aria-hidden="true"><span class="switch-thumb"></span></span>
<span>Enabled</span>
</label>
</div>
<div class="field-grid">
<div class="field with-button full">
<label>ICOM Executable</label>
<div class="input-action action-one">
<input data-service="icom" data-field="command" value="${escapeAttr(config.command)}" placeholder="forge-icom.exe" />
<button class="icon-button" type="button" data-picker="file" data-service-target="icom" data-target='[data-service="icom"][data-field="command"]' title="Select executable" aria-label="Select executable"><span class="svg-icon icon-browse"></span></button>
</div>
</div>
<div class="field with-button full">
<label>Working Directory</label>
<div class="input-action action-one">
<input data-service="icom" data-field="working_dir" value="${escapeAttr(config.working_dir)}" />
<button class="icon-button" type="button" data-picker="directory" data-service-target="icom" data-target='[data-service="icom"][data-field="working_dir"]' title="Select folder" aria-label="Select folder"><span class="svg-icon icon-browse"></span></button>
</div>
</div>
<div class="field">
<label>Health Host</label>
<input data-service="icom" data-field="health_host" value="${escapeAttr(config.health_host)}" />
</div>
<div class="field">
<label>Health Port</label>
<input data-service="icom" data-field="health_port" type="number" min="1" max="65535" value="${config.health_port}" />
</div>
<div class="field full">
<label>Arguments</label>
<div class="args-list" data-service="icom" data-field="args">
${renderArgumentFields("icom", config.args)}
</div>
</div>
</div>
</section>
`;
}
function renderArmaSettings(config) {
const parsed = parseArmaArgs(config.args);
return `
<section class="settings-panel active">
<div class="settings-header">
<h2>Arma 3 Server</h2>
<label class="toggle-row">
<input type="checkbox" data-service="arma" data-field="enabled" ${config.enabled ? "checked" : ""} />
<span class="switch-track" aria-hidden="true"><span class="switch-thumb"></span></span>
<span>Enabled</span>
</label>
</div>
<div class="field-grid arma-grid">
<div class="field with-button full">
<label>Server Executable</label>
<div class="input-action action-one">
<input data-service="arma" data-field="command" value="${escapeAttr(config.command)}" placeholder="arma3server_x64.exe" />
<button class="icon-button" type="button" data-picker="file" data-service-target="arma" data-target='[data-service="arma"][data-field="command"]' title="Select executable" aria-label="Select executable"><span class="svg-icon icon-browse"></span></button>
</div>
</div>
<div class="field with-button full">
<label>Server Directory</label>
<div class="input-action action-one">
<input data-service="arma" data-field="working_dir" value="${escapeAttr(config.working_dir)}" />
<button class="icon-button" type="button" data-picker="directory" data-service-target="arma" data-target='[data-service="arma"][data-field="working_dir"]' title="Select folder" aria-label="Select folder"><span class="svg-icon icon-browse"></span></button>
</div>
</div>
<div class="field">
<label>Health Host</label>
<input data-service="arma" data-field="health_host" value="${escapeAttr(config.health_host)}" />
</div>
<div class="field">
<label>Health Port</label>
<input data-service="arma" data-field="health_port" type="number" min="1" max="65535" value="${config.health_port}" />
</div>
<div class="field full">
<label>Launch Options</label>
<div class="args-list arma-args" data-service="arma" data-field="args">
${serverConfigInput(parsed.config)}
${serverConfigTemplateInput()}
${argumentInput("Port", "port", parsed.port)}
${argumentInput("Profiles", "profiles", parsed.profiles)}
${argumentInput("Name", "name", parsed.name)}
${argumentInput("Mods", "mods", parsed.mods)}
${argumentInput("Server Mods", "serverMods", parsed.serverMods)}
${toggleArgument("AutoInit", "autoinit", parsed.autoinit)}
${toggleArgument("ReportNonNetworkObject", "reportNonNetworkObject", parsed.reportNonNetworkObject)}
${toggleArgument("BattlEye", "battleye", parsed.battleye)}
${argumentInput("Misc Arguments", "misc", parsed.misc.join(" "), "full")}
</div>
</div>
</div>
</section>
`;
}
function persistActiveSettingsForm() {
const form = document.getElementById("configForm");
if (!snapshot || !form.children.length) return;
snapshot.config = readFormConfig();
}
function renderArgumentFields(service, args) {
if (service === "surrealdb") {
const parsed = parseSurrealArgs(args);
return `
${argumentInput("Subcommand", "subcommand", parsed.subcommand)}
${argumentInput("Root User", "user", parsed.user)}
${argumentInput("Root Password", "pass", parsed.pass)}
${argumentInput("Bind Address", "bind", parsed.bind)}
${argumentInput("Database Path", "database", parsed.database)}
${argumentInput("Misc Arguments", "misc", parsed.misc.join(" "), "full")}
`;
}
const label = service === "arma" ? "Startup Parameters" : "Misc Arguments";
return argumentInput(label, "misc", args.join(" "), "full");
}
function argumentInput(label, key, value, className = "") {
return `
<div class="arg-field ${className}">
<label>${label}</label>
<input class="arg-input" data-arg-key="${key}" value="${escapeAttr(value)}" />
</div>
`;
}
function serverConfigInput(value) {
return `
<div class="arg-field full">
<label>Server Config</label>
<div class="input-action action-three">
<input class="arg-input" data-arg-key="config" value="${escapeAttr(value)}" placeholder="server.cfg" />
<button class="icon-button" type="button" data-picker="config" data-service-target="arma" data-target='[data-arg-key="config"]' title="Select config" aria-label="Select config"><span class="svg-icon icon-browse"></span></button>
<button class="icon-button" type="button" data-create-server-config title="Create default config" aria-label="Create default config"><span class="svg-icon icon-new"></span></button>
<button class="icon-button" type="button" data-edit-server-config title="Edit config" aria-label="Edit config"><span class="svg-icon icon-edit"></span></button>
</div>
</div>
`;
}
function serverConfigTemplateInput() {
return `
<div class="arg-field">
<label>Config Template</label>
<select data-config-template>
<option value="server" ${selectedAttr(selectedArmaConfigTemplate === "server")}>Server</option>
<option value="basic" ${selectedAttr(selectedArmaConfigTemplate === "basic")}>Basic Network</option>
</select>
</div>
`;
}
function toggleArgument(label, key, value) {
return `
<label class="arg-toggle">
<input type="checkbox" class="arg-input" data-arg-key="${key}" ${value ? "checked" : ""} />
${label}
</label>
`;
}
function readArgumentFields(service, container) {
const value = (key) => container.querySelector(`[data-arg-key="${key}"]`)?.value.trim() || "";
const checked = (key) => Boolean(container.querySelector(`[data-arg-key="${key}"]`)?.checked);
if (service === "surrealdb") {
const args = [];
if (value("subcommand")) args.push(value("subcommand"));
if (value("user")) args.push("--user", value("user"));
if (value("pass")) args.push("--pass", value("pass"));
if (value("bind")) args.push("--bind", value("bind"));
args.push(...splitMiscArgs(value("misc")));
if (value("database")) args.push(value("database"));
return args;
}
if (service === "arma") {
const args = [];
if (value("config")) args.push(`-config=${value("config")}`);
if (value("port")) args.push(`-port=${value("port")}`);
if (value("profiles")) args.push(`-profiles=${value("profiles")}`);
if (value("name")) args.push(`-name=${value("name")}`);
if (value("mods")) args.push(`-mod=${value("mods")}`);
if (value("serverMods")) args.push(`-serverMod=${value("serverMods")}`);
checked("autoinit") && args.push("-autoinit");
checked("reportNonNetworkObject") && args.push("-reportNonNetworkObject");
args.push(checked("battleye") ? "-battleye" : "-noBattlEye");
args.push(...splitMiscArgs(value("misc")));
return args;
}
return splitMiscArgs(value("misc"));
}
function parseSurrealArgs(args) {
const parsed = {
subcommand: "",
user: "",
pass: "",
bind: "",
database: "",
misc: [],
};
const remaining = [...args];
if (remaining[0] && !remaining[0].startsWith("-")) {
parsed.subcommand = remaining.shift();
}
for (let index = 0; index < remaining.length; index += 1) {
const arg = remaining[index];
if (arg === "--user") {
parsed.user = remaining[++index] || "";
} else if (arg === "--pass") {
parsed.pass = remaining[++index] || "";
} else if (arg === "--bind") {
parsed.bind = remaining[++index] || "";
} else if (!arg.startsWith("-")) {
parsed.database = arg;
} else {
parsed.misc.push(arg);
}
}
return parsed;
}
function parseArmaArgs(args) {
const parsed = {
config: "",
port: "",
profiles: "",
name: "",
mods: "",
serverMods: "",
autoinit: false,
reportNonNetworkObject: false,
battleye: true,
misc: [],
};
for (const arg of args) {
if (!arg) continue;
const lowerArg = arg.toLowerCase();
if (lowerArg.startsWith("-config=")) {
parsed.config = arg.slice("-config=".length);
} else if (lowerArg.startsWith("-port=")) {
parsed.port = arg.slice("-port=".length);
} else if (lowerArg.startsWith("-profiles=")) {
parsed.profiles = arg.slice("-profiles=".length);
} else if (lowerArg.startsWith("-name=")) {
parsed.name = arg.slice("-name=".length);
} else if (lowerArg.startsWith("-mod=")) {
parsed.mods = arg.slice("-mod=".length);
} else if (lowerArg.startsWith("-servermod=")) {
parsed.serverMods = arg.slice("-serverMod=".length);
} else if (lowerArg === "-nobattleye") {
parsed.battleye = false;
} else if (lowerArg === "-battleye") {
parsed.battleye = true;
} else if (lowerArg === "-autoinit") {
parsed.autoinit = true;
} else if (lowerArg === "-reportnonnetworkobject") {
parsed.reportNonNetworkObject = true;
} else {
parsed.misc.push(arg);
}
}
return parsed;
}
function splitMiscArgs(value) {
return value.split(/\s+/).map((arg) => arg.trim()).filter(Boolean);
}
async function pickPath(kind, targetSelector, service) {
const open = window.__TAURI__?.dialog?.open;
if (!open) {
throw new Error("Tauri dialog API is not available in this window.");
}
const selected = await open({
multiple: false,
directory: kind === "directory",
filters: pickerFilters(kind),
});
if (!selected) return;
const input = document.querySelector(targetSelector);
if (!input) return;
const finalPath = normalizePickedExecutable(service, selected);
input.value = finalPath;
if (kind === "file" && targetSelector.includes('data-field="command"')) {
const workingDir = document.querySelector(`[data-service="${service}"][data-field="working_dir"]`);
if (workingDir) {
workingDir.value = finalPath.replace(/[\\/][^\\/]+$/, "");
}
}
configDirty = true;
persistActiveSettingsForm();
if (kind === "config") {
await openConfigEditor(resolveArmaConfigPath(finalPath));
}
}
function pickerFilters(kind) {
if (kind === "file") {
return [{ name: "Executable", extensions: ["exe"] }];
}
if (kind === "config") {
return [{ name: "Arma Server Config", extensions: ["cfg"] }];
}
return undefined;
}
async function createArmaServerConfig(template) {
const save = window.__TAURI__?.dialog?.save;
if (!save) {
throw new Error("Tauri save dialog API is not available in this window.");
}
const workingDir = document.querySelector('[data-service="arma"][data-field="working_dir"]')?.value.trim();
const defaultPath = workingDir ? `${workingDir}\\server.cfg` : "server.cfg";
const selected = await save({
defaultPath,
filters: [{ name: "Arma Server Config", extensions: ["cfg"] }],
});
if (!selected) return;
await tauriInvoke("create_arma_server_config", { path: selected, template });
const input = document.querySelector('[data-arg-key="config"]');
if (input) {
input.value = selected;
configDirty = true;
persistActiveSettingsForm();
}
await openConfigEditor(selected);
}
function resolveArmaConfigPath(path) {
if (/^[a-zA-Z]:[\\/]/.test(path) || path.startsWith("\\\\")) {
return path;
}
const workingDir = document.querySelector('[data-service="arma"][data-field="working_dir"]')?.value.trim();
return workingDir ? `${workingDir}\\${path}` : path;
}
async function openConfigEditor(path) {
if (!path) return;
activeEditorPath = path;
const content = await tauriInvoke("read_text_file", { path });
document.getElementById("editorPath").textContent = path;
document.getElementById("configEditor").value = content;
document.getElementById("editorOverlay").hidden = false;
document.getElementById("configEditor").focus();
}
function closeConfigEditor() {
document.getElementById("editorOverlay").hidden = true;
}
async function saveConfigEditor() {
if (!activeEditorPath) return;
const content = document.getElementById("configEditor").value;
await tauriInvoke("write_text_file", { path: activeEditorPath, content });
}
function normalizePickedExecutable(service, path) {
if (service !== "arma") {
return path;
}
const fileName = path.split(/[\\/]/).pop()?.toLowerCase() || "";
if (isArmaServerExecutable(fileName)) {
return path;
}
if (fileName === "arma3_x64.exe" || fileName === "arma3.exe") {
alert("That is the Arma 3 client executable. Forge Host will use arma3server_x64.exe from the same directory instead.");
return path.replace(/[\\/][^\\/]+$/, "\\arma3server_x64.exe");
}
alert("Select arma3server_x64.exe for dedicated server hosting. The app will block client executables at launch.");
return path;
}
function isArmaServerExecutable(fileName) {
return fileName.startsWith("arma3server");
}
async function runAction(action) {
try {
await action();
} catch (error) {
alert(String(error));
await refresh();
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll('"', "&quot;");
}
function selectedAttr(selected) {
return selected ? "selected" : "";
}
refresh();
syncRefreshTimer();

149
bin/host/src/index.html Normal file
View File

@ -0,0 +1,149 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FORGE Host</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="shell">
<aside class="rail">
<div class="brand">
<span class="brand-mark">F</span>
<span>FORGE Host</span>
</div>
<nav class="nav">
<button class="nav-button active" type="button" data-view="overview" title="Overview">
<span class="svg-icon icon-dashboard"></span>
<span>Overview</span>
</button>
<button class="nav-button" type="button" data-view="settings" title="Settings">
<span class="svg-icon icon-database"></span>
<span>SurrealDB</span>
</button>
<button class="nav-button" type="button" data-view="settings" data-service="icom" title="ICOM">
<span class="svg-icon icon-icom"></span>
<span>ICOM</span>
</button>
<button class="nav-button" type="button" data-view="settings" data-service="arma" title="Arma 3 Server">
<span class="svg-icon icon-server"></span>
<span>Arma Server</span>
</button>
<button class="nav-button" type="button" data-view="logs" title="Logs">
<span class="svg-icon icon-logs"></span>
<span>Logs</span>
</button>
</nav>
<div class="config-path">
<span>Config</span>
<strong id="configPath">Loading</strong>
</div>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<h1 id="viewTitle">Overview</h1>
<p id="viewSubtitle">Local service control for Forge server hosting.</p>
</div>
<div class="top-actions">
<div id="bulkActions" class="bulk-actions" style="display: none;">
<button id="startAllButton" class="start icon-button" type="button" aria-label="Start All"
title="Start all services">
<span class="svg-icon icon-play"></span>
</button>
<button id="stopAllButton" class="stop icon-button" type="button" aria-label="Stop All"
title="Stop all services">
<span class="svg-icon icon-stop"></span>
</button>
</div>
<button id="refreshButton" class="primary icon-button" type="button" aria-label="Refresh" title="Refresh">
<span class="svg-icon icon-refresh"></span>
</button>
<button id="saveButton" class="icon-button" type="button" aria-label="Save" title="Save">
<span class="svg-icon icon-save"></span>
</button>
</div>
</header>
<section id="overviewView" class="view active">
<div id="serviceGrid" class="service-grid"></div>
</section>
<section id="settingsView" class="view">
<form id="configForm" class="settings-grid"></form>
</section>
<section id="logsView" class="view">
<div id="logOutput" class="log-output"></div>
</section>
</section>
</main>
<div id="editorOverlay" class="editor-overlay" hidden>
<section class="editor-modal">
<header class="editor-header">
<div>
<h2>Server Config</h2>
<p id="editorPath"></p>
</div>
<button id="editorCloseButton" class="icon-button" type="button" title="Close" aria-label="Close">
<span class="svg-icon icon-close"></span>
</button>
</header>
<textarea id="configEditor" spellcheck="false"></textarea>
<footer class="editor-actions">
<button id="editorReloadButton" class="primary" type="button" aria-label="Refresh">
<span class="svg-icon icon-refresh"></span>
</button>
<button id="editorSaveButton" class="icon-button" type="button" aria-label="Save">
<span class="svg-icon icon-save"></span>
</button>
</footer>
</section>
</div>
<template id="serviceCardTemplate">
<article class="service-card">
<header>
<div>
<div class="service-title-row">
<h2></h2>
<span class="ping-pill"></span>
</div>
<p class="command-line"></p>
</div>
<span class="status-pill"></span>
</header>
<dl class="metrics">
<div>
<dt>Process</dt>
<dd class="pid"></dd>
</div>
<div>
<dt>Health</dt>
<dd class="health"></dd>
</div>
<div>
<dt>Enabled</dt>
<dd class="enabled"></dd>
</div>
</dl>
<div class="card-actions">
<button class="start icon-button" type="button" aria-label="Start">
<span class="svg-icon icon-play"></span>
</button>
<button class="stop icon-button" type="button" aria-label="Stop">
<span class="svg-icon icon-stop"></span>
</button>
</div>
</article>
</template>
<script src="./app.js"></script>
</body>
</html>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 1H6L9 4H16V14H0V1Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path fill="#000000" d="M195.2 195.2a64 64 0 0 1 90.496 0L512 421.504 738.304 195.2a64 64 0 0 1 90.496 90.496L602.496 512 828.8 738.304a64 64 0 0 1-90.496 90.496L512 602.496 285.696 828.8a64 64 0 0 1-90.496-90.496L421.504 512 195.2 285.696a64 64 0 0 1 0-90.496z"/></svg>

After

Width:  |  Height:  |  Size: 482 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="dashboard" class="icon glyph"><rect x="2" y="2" width="9" height="11" rx="2"></rect><rect x="13" y="2" width="9" height="7" rx="2"></rect><rect x="2" y="15" width="9" height="7" rx="2"></rect><rect x="13" y="11" width="9" height="11" rx="2"></rect></svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
viewBox="0 0 600 600"
version="1.1"
id="svg9724"
sodipodi:docname="database.svg"
inkscape:version="1.2.2 (1:1.2.2+202212051550+b0a8486541)"
width="600"
height="600"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs9728" />
<sodipodi:namedview
id="namedview9726"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="true"
inkscape:zoom="0.42059316"
inkscape:cx="148.59966"
inkscape:cy="296.01052"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="1080"
inkscape:window-maximized="1"
inkscape:current-layer="svg9724"
showguides="true">
<inkscape:grid
type="xygrid"
id="grid9972"
originx="0"
originy="0" />
<sodipodi:guide
position="300,-90"
orientation="1,0"
id="guide385"
inkscape:locked="false" />
<sodipodi:guide
position="140,100"
orientation="0,-1"
id="guide1388"
inkscape:locked="false" />
<sodipodi:guide
position="140,100"
orientation="0,-1"
id="guide2256"
inkscape:locked="false" />
<sodipodi:guide
position="0,475"
orientation="0,-1"
id="guide1920"
inkscape:locked="false" />
</sodipodi:namedview>
<path
id="path3428"
style="color:#000000;fill:#010101;stroke-linejoin:round;-inkscape-stroke:none;paint-order:stroke fill markers"
d="M 300 0 C 221.30245 0 150.09841 8.0113158 97.068359 21.535156 C 70.553346 28.297076 48.605538 36.277916 31.677734 46.484375 C 16.579982 55.587421 3.2445893 67.928721 0.53125 85 L 0 85 L 0 90 C 0 95.160045 3.6392602 102.94345 17.03125 112.83789 C 30.423241 122.73233 52.11942 133.00486 79.691406 141.62109 C 134.83535 158.85361 213.32376 170 300 170 C 386.67624 170 465.16467 158.85361 520.30859 141.62109 C 547.8806 133.00486 569.57675 122.73233 582.96875 112.83789 C 596.36075 102.94345 600 95.160045 600 90 L 599.87305 90 C 599.19452 70.318664 584.84711 56.447884 568.32227 46.484375 C 551.39442 36.277916 529.44664 28.297076 502.93164 21.535156 C 449.90159 8.0113158 378.69755 0 300 0 z M 0 149.67969 L 0 234.10742 C 0.70499641 239.21983 4.6599347 246.30446 16.722656 255.2168 C 30.11466 265.11125 51.810798 275.38376 79.382812 284 C 134.52681 301.23251 213.01506 312.37891 299.69141 312.37891 C 386.36774 312.37891 464.85602 301.23251 520 284 C 547.57201 275.38376 569.26815 265.11125 582.66016 255.2168 C 596.05215 245.32235 599.69141 237.53895 599.69141 232.37891 L 600 232.37891 L 600 149.67969 C 581.93283 161.57337 559.1282 171.3983 532.24023 179.80078 C 471.56758 198.761 390.05399 210 300 210 C 209.94601 210 128.43244 198.761 67.759766 179.80078 C 40.871811 171.3983 18.067172 161.57337 0 149.67969 z M 600 291.79688 C 590.25148 298.2521 579.18165 304.12941 566.75 309.46875 C 556.06951 314.05598 544.44003 318.27081 531.93164 322.17969 C 471.2589 341.13992 389.74549 352.37891 299.69141 352.37891 C 209.63733 352.37891 128.12391 341.13993 67.451172 322.17969 C 40.720883 313.82647 18.016718 304.0712 0 292.27148 L 0 380 C 0 385.16005 3.6392334 392.94343 17.03125 402.83789 C 30.423267 412.73235 52.119364 423.00484 79.691406 431.62109 C 134.83545 448.85363 213.32358 460 300 460 C 386.67642 460 465.16455 448.85363 520.30859 431.62109 C 547.88068 423.00484 569.57666 412.73235 582.96875 402.83789 C 596.36074 392.94343 600 385.16005 600 380 L 600 291.79688 z M 0 439.67969 L 0 508.59375 L 0 515 L 0.53125 515 C 3.2445947 532.0713 16.579952 544.41257 31.677734 553.51562 C 48.605572 563.7221 70.553292 571.70292 97.068359 578.46484 C 150.09851 591.98873 221.30229 600 300 600 C 378.69771 600 449.90149 591.98873 502.93164 578.46484 C 529.4467 571.70292 551.3944 563.7221 568.32227 553.51562 C 583.42003 544.41257 596.7554 532.0713 599.46875 515 L 600 515 L 600 508.59375 L 600 439.67969 C 581.93278 451.57339 559.1283 461.39828 532.24023 469.80078 C 471.56747 488.76104 390.05417 500 300 500 C 209.94583 500 128.43256 488.76104 67.759766 469.80078 C 40.871757 461.39828 18.067208 451.57339 0 439.67969 z " />
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="m3.99 16.854-1.314 3.504a.75.75 0 0 0 .966.965l3.503-1.314a3 3 0 0 0 1.068-.687L18.36 9.175s-.354-1.061-1.414-2.122c-1.06-1.06-2.122-1.414-2.122-1.414L4.677 15.786a3 3 0 0 0-.687 1.068zm12.249-12.63 1.383-1.383c.248-.248.579-.406.925-.348.487.08 1.232.322 1.934 1.025.703.703.945 1.447 1.025 1.934.058.346-.1.677-.348.925L19.774 7.76s-.353-1.06-1.414-2.12c-1.06-1.062-2.121-1.415-2.121-1.415z" fill="#000000"/></svg>

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>arrows-repeat</title>
<path d="M3.25 14.008c0.002-0.234 0.113-5.754 6.75-5.758h14.981l-2.866 2.866c-0.226 0.226-0.366 0.539-0.366 0.884 0 0.691 0.56 1.251 1.251 1.251 0.346 0 0.658-0.14 0.885-0.367v0l5-5c0.012-0.012 0.016-0.029 0.027-0.041 0.099-0.103 0.18-0.223 0.239-0.356l0.003-0.008 0-0.003c0.051-0.13 0.080-0.28 0.080-0.438 0-0.071-0.006-0.14-0.017-0.208l0.001 0.007c-0.008-0.059-0.018-0.11-0.031-0.16l0.002 0.009c-0.052-0.223-0.158-0.416-0.305-0.571l0.001 0.001-5-5c-0.226-0.227-0.539-0.367-0.885-0.367-0.691 0-1.251 0.56-1.251 1.251 0 0.345 0.14 0.658 0.366 0.884v0l2.867 2.866h-14.983c-7.274 0.005-9.23 5.394-9.249 8.242-0 0.002-0 0.005-0 0.008 0 0.688 0.555 1.246 1.242 1.25h0.008c0.687-0 1.245-0.555 1.25-1.242v-0zM30.014 16.753c-0.676 0.027-1.22 0.559-1.263 1.229l-0 0.004c-0.010 0.589-0.283 5.761-6.75 5.765h-14.981l2.865-2.865c0.227-0.226 0.367-0.539 0.367-0.885 0-0.691-0.56-1.251-1.251-1.251-0.345 0-0.658 0.14-0.884 0.366v0l-5 5c-0.012 0.012-0.016 0.029-0.027 0.041-0.1 0.103-0.182 0.224-0.241 0.357l-0.003 0.008-0 0.003c-0.038 0.093-0.065 0.2-0.073 0.312l-0 0.004c-0.003 0.033-0.004 0.072-0.004 0.11 0 0.075 0.005 0.149 0.016 0.221l-0.001-0.008c0.039 0.28 0.159 0.527 0.335 0.722l-0.001-0.001 5 5c0.226 0.226 0.539 0.366 0.884 0.366 0.691 0 1.251-0.56 1.251-1.251 0-0.346-0.14-0.658-0.367-0.885l-2.866-2.865h14.982c7.275-0.006 9.232-5.395 9.25-8.242 0-0.002 0-0.005 0-0.007 0-0.685-0.552-1.241-1.236-1.248h-0.001z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Combined Shape Copy 9</title>
<desc>Created with Sketch.</desc>
<g id="Changes,-Tests,-Widgets" stroke="none" stroke-width="1" fill-rule="evenodd">
<g id="Tests-History" transform="translate(-1156.000000, -1289.000000)">
<path d="M1168,1289 L1156,1289 L1156,1301 L1168,1301 L1168,1289 Z M1165,1292 L1159,1292 L1159,1293 L1165,1293 L1165,1292 Z M1159,1294 L1165,1294 L1165,1295 L1159,1295 L1159,1294 Z M1165,1296 L1159,1296 L1159,1297 L1165,1297 L1165,1296 Z" id="Combined-Shape-Copy-9">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 845 B

View File

@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title/>
<g id="Complete">
<g data-name="add" id="add-2">
<g>
<line fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" x1="12" x2="12" y1="19" y2="5"/>
<line fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" x1="5" x2="19" y1="12" y2="12"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 552 B

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>play</title>
<path d="M5.92 24.096q0 1.088 0.928 1.728 0.512 0.288 1.088 0.288 0.448 0 0.896-0.224l16.16-8.064q0.48-0.256 0.8-0.736t0.288-1.088-0.288-1.056-0.8-0.736l-16.16-8.064q-0.448-0.224-0.896-0.224-0.544 0-1.088 0.288-0.928 0.608-0.928 1.728v16.16z"></path>
</svg>

After

Width:  |  Height:  |  Size: 516 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 3V8M21 8H16M21 8L18 5.29168C16.4077 3.86656 14.3051 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21C16.2832 21 19.8675 18.008 20.777 14" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H9M8 20V14C8 13.4477 8.44772 13 9 13H15C15.5523 13 16 13.4477 16 14V20M8 20H16M16 20H18C19.1046 20 20 19.1046 20 18V8.82843C20 8.29799 19.7893 7.78929 19.4142 7.41421L16.5858 4.58579C16.2107 4.21071 15.702 4 15.1716 4H15M15 4V7C15 7.55228 14.5523 8 14 8H10C9.44772 8 9 7.55228 9 7V4M15 4H9" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 669 B

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 60 60" xml:space="preserve">
<g>
<path d="M0,24.108v11.783c0,0.974,0.314,1.868,0.835,2.608h58.329C59.686,37.76,60,36.865,60,35.892V24.108
c0-0.974-0.314-1.868-0.835-2.608H0.835C0.314,22.24,0,23.135,0,24.108z M52,30.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1
S51.448,30.5,52,30.5z M50,27.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S49.448,27.5,50,27.5z M48,30.5c0.552,0,1,0.448,1,1
s-0.448,1-1,1s-1-0.448-1-1S47.448,30.5,48,30.5z M46,27.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S45.448,27.5,46,27.5z
M44,30.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S43.448,30.5,44,30.5z M42,27.5c0.552,0,1,0.448,1,1s-0.448,1-1,1
s-1-0.448-1-1S41.448,27.5,42,27.5z M40,30.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S39.448,30.5,40,30.5z M38,27.5
c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S37.448,27.5,38,27.5z M36,30.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1
S35.448,30.5,36,30.5z M34,27.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S33.448,27.5,34,27.5z M10.5,25.5
c2.481,0,4.5,2.019,4.5,4.5s-2.019,4.5-4.5,4.5S6,32.481,6,30S8.019,25.5,10.5,25.5z"/>
<path d="M59.165,19.5C59.686,18.76,60,17.865,60,16.892V5.108C60,2.567,57.933,0.5,55.392,0.5H4.608C2.067,0.5,0,2.567,0,5.108
v11.783c0,0.974,0.314,1.868,0.835,2.608H59.165z M52,11.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S51.448,11.5,52,11.5z
M50,8.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S49.448,8.5,50,8.5z M48,11.5c0.552,0,1,0.448,1,1s-0.448,1-1,1
s-1-0.448-1-1S47.448,11.5,48,11.5z M46,8.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S45.448,8.5,46,8.5z M44,11.5
c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S43.448,11.5,44,11.5z M42,8.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1
S41.448,8.5,42,8.5z M40,11.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S39.448,11.5,40,11.5z M38,8.5c0.552,0,1,0.448,1,1
s-0.448,1-1,1s-1-0.448-1-1S37.448,8.5,38,8.5z M36,11.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S35.448,11.5,36,11.5z
M34,8.5c0.552,0,1,0.448,1,1s-0.448,1-1,1s-1-0.448-1-1S33.448,8.5,34,8.5z M10.5,6.5c2.481,0,4.5,2.019,4.5,4.5
s-2.019,4.5-4.5,4.5S6,13.481,6,11S8.019,6.5,10.5,6.5z"/>
<path d="M0.835,40.5C0.314,41.24,0,42.135,0,43.108v11.783C0,57.433,2.067,59.5,4.608,59.5h50.783c2.541,0,4.608-2.067,4.608-4.608
V43.108c0-0.974-0.314-1.868-0.835-2.608H0.835z M10.5,53.5C8.019,53.5,6,51.481,6,49s2.019-4.5,4.5-4.5S15,46.519,15,49
S12.981,53.5,10.5,53.5z M34,48.5c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S34.552,48.5,34,48.5z M36,51.5
c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S36.552,51.5,36,51.5z M38,48.5c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1
S38.552,48.5,38,48.5z M40,51.5c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S40.552,51.5,40,51.5z M42,48.5c-0.552,0-1-0.448-1-1
s0.448-1,1-1s1,0.448,1,1S42.552,48.5,42,48.5z M44,51.5c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S44.552,51.5,44,51.5z
M46,48.5c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S46.552,48.5,46,48.5z M48,51.5c-0.552,0-1-0.448-1-1s0.448-1,1-1
s1,0.448,1,1S48.552,51.5,48,51.5z M50,48.5c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S50.552,48.5,50,48.5z M52,51.5
c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S52.552,51.5,52,51.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>stop</title>
<path d="M5.92 24.096q0 0.832 0.576 1.408t1.44 0.608h16.128q0.832 0 1.44-0.608t0.576-1.408v-16.16q0-0.832-0.576-1.44t-1.44-0.576h-16.128q-0.832 0-1.44 0.576t-0.576 1.44v16.16z"></path>
</svg>

After

Width:  |  Height:  |  Size: 450 B

857
bin/host/src/styles.css Normal file
View File

@ -0,0 +1,857 @@
:root {
color-scheme: dark;
--bg: #0b0d10;
--panel: #15181e;
--panel-soft: #1c2028;
--line: #343a45;
--text: #f3f0e8;
--muted: #aba79f;
--gold: #ffe681;
--green: #21d07a;
--red: #ff5b66;
--blue: #69a7ff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 940px;
min-height: 620px;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: Inter, "Segoe UI", Arial, sans-serif;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
border: 1px solid rgba(255, 230, 129, 0.42);
background: rgba(255, 230, 129, 0.08);
color: var(--gold);
height: 38px;
padding: 0 14px;
cursor: pointer;
border-radius: 6px;
}
button:hover {
border-color: var(--gold);
background: rgba(255, 230, 129, 0.14);
}
button:disabled {
cursor: default;
opacity: 0.42;
}
button.primary {
background: var(--gold);
color: #101114;
border-color: var(--gold);
font-weight: 700;
}
button.primary:hover:not(:disabled) {
background: #fff0a8;
border-color: #fff0a8;
box-shadow: 0 0 0 3px rgba(255, 230, 129, 0.14);
}
button.icon-button {
display: grid;
place-items: center;
width: 42px;
min-width: 42px;
height: 42px;
padding: 0;
font-size: 18px;
line-height: 1;
}
.shell {
display: grid;
grid-template-columns: 260px 1fr;
height: 100vh;
}
.rail {
display: flex;
flex-direction: column;
gap: 28px;
padding: 24px 18px;
background: #101216;
border-right: 1px solid #252a33;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 800;
color: var(--gold);
}
.brand-mark {
display: grid;
width: 38px;
height: 38px;
place-items: center;
background: var(--gold);
color: #111216;
border-radius: 6px;
}
.nav {
display: grid;
gap: 10px;
}
.nav-button {
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center;
width: 100%;
height: 48px;
text-align: left;
color: var(--muted);
border-color: #3a3d2c;
background: transparent;
}
.nav-button.active {
color: #111216;
border-color: var(--gold);
background: var(--gold);
font-weight: 800;
}
.nav-button[data-view="settings"]::after {
content: "";
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--muted);
transition: background-color 180ms ease, box-shadow 180ms ease;
}
.nav-button.service-enabled::after {
background: var(--green);
animation: gumball-ping-green 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.nav-button.service-stopped::after {
background: var(--red);
animation: gumball-ping-red 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.nav-button.service-disabled::after {
background: #1a1d23;
border: 1px solid rgba(255, 230, 129, 0.38);
animation: gumball-ping-gold 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.svg-icon {
display: block;
width: 18px;
height: 18px;
flex: 0 0 auto;
background: currentColor;
-webkit-mask: var(--icon-url) center / contain no-repeat;
mask: var(--icon-url) center / contain no-repeat;
}
.icon-browse {
--icon-url: url("./public/browse.svg");
}
.icon-close {
--icon-url: url("./public/close.svg");
}
.icon-dashboard {
--icon-url: url("./public/dashboard.svg");
}
.icon-database {
--icon-url: url("./public/database.svg");
}
.icon-edit {
--icon-url: url("./public/edit.svg");
}
.icon-logs {
--icon-url: url("./public/logs.svg");
}
.icon-icom {
--icon-url: url("./public/icom.svg");
}
.icon-new {
--icon-url: url("./public/new.svg");
}
.icon-refresh {
--icon-url: url("./public/refresh.svg");
}
.icon-save {
--icon-url: url("./public/save.svg");
}
.icon-server {
--icon-url: url("./public/server.svg");
}
.icon-play {
--icon-url: url("./public/play.svg");
}
.icon-stop {
--icon-url: url("./public/stop.svg");
}
.config-path {
margin-top: auto;
display: grid;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.config-path strong {
overflow-wrap: anywhere;
color: var(--text);
font-weight: 500;
}
.workspace {
display: grid;
grid-template-rows: auto 1fr;
min-width: 0;
min-height: 0;
background:
linear-gradient(rgba(11, 13, 16, 0.88), rgba(11, 13, 16, 0.94)),
url("./public/forge.png") center / 512px no-repeat;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 28px 32px 20px;
border-bottom: 1px solid #252a33;
}
.topbar h1 {
margin: 0;
font-size: 28px;
line-height: 1.1;
color: var(--gold);
}
.topbar p {
margin: 8px 0 0;
color: var(--muted);
}
.top-actions {
display: flex;
gap: 10px;
align-items: center;
}
.bulk-actions {
display: flex;
align-items: center;
gap: 8px;
}
.top-actions .icon-button {
width: 42px;
min-width: 42px;
height: 42px;
border-radius: 8px;
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.card-actions .icon-button {
width: 36px;
min-width: 36px;
height: 36px;
border-radius: 10px;
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease;
}
.view {
display: none;
min-height: 0;
max-height: 100%;
overflow: auto;
padding: 28px 32px 36px;
}
.view.active {
display: block;
}
.service-grid {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 18px;
}
.service-card,
.settings-panel {
border: 1px solid rgba(255, 230, 129, 0.22);
background: rgba(15, 17, 21, 0.86);
border-radius: 8px;
}
.service-card {
display: grid;
gap: 22px;
padding: 20px;
}
.service-card header {
display: flex;
justify-content: space-between;
gap: 14px;
}
.service-card h2,
.settings-panel h2 {
margin: 0;
font-size: 19px;
color: var(--text);
}
.service-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.ping-pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(114, 119, 128, 0.55);
background: rgba(114, 119, 128, 0.14);
color: #c1c6cf;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.01em;
}
.ping-pill.good {
border-color: rgba(33, 208, 122, 0.45);
background: rgba(33, 208, 122, 0.14);
color: #92f7c6;
}
.ping-pill.warn {
border-color: rgba(255, 230, 129, 0.5);
background: rgba(255, 230, 129, 0.14);
color: #ffe9a8;
}
.ping-pill.bad {
border-color: rgba(255, 91, 102, 0.5);
background: rgba(255, 91, 102, 0.14);
color: #ffb2b8;
}
.ping-pill.unavailable {
border-color: rgba(114, 119, 128, 0.55);
background: rgba(114, 119, 128, 0.14);
color: #c1c6cf;
}
.command-line {
margin: 8px 0 0;
color: var(--muted);
overflow-wrap: anywhere;
}
.status-pill {
align-self: start;
min-width: 82px;
padding: 6px 8px;
border-radius: 999px;
text-align: center;
font-size: 12px;
font-weight: 800;
color: #101114;
background: var(--muted);
}
.status-pill.running {
background: var(--green);
}
.status-pill.stopped {
background: var(--red);
}
.metrics {
display: grid;
gap: 12px;
margin: 0;
}
.metrics div {
display: flex;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 9px;
}
.metrics dt {
color: var(--muted);
}
.metrics dd {
margin: 0;
text-align: right;
}
.card-actions {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
margin-top: auto;
}
.bulk-actions .start,
.card-actions .start {
color: #ffe48a;
border-color: rgba(255, 230, 129, 0.42);
background: rgba(255, 230, 129, 0.08);
}
.bulk-actions .start:hover:not(:disabled),
.card-actions .start:hover:not(:disabled) {
border-color: #ffe48a;
background: rgba(255, 230, 129, 0.16);
box-shadow: 0 0 0 3px rgba(255, 230, 129, 0.1);
}
.bulk-actions .stop,
.card-actions .stop {
color: #ff9ca3;
border-color: rgba(255, 91, 102, 0.45);
background: rgba(255, 91, 102, 0.08);
}
.bulk-actions .stop:hover:not(:disabled),
.card-actions .stop:hover:not(:disabled) {
border-color: #ff9ca3;
background: rgba(255, 91, 102, 0.16);
box-shadow: 0 0 0 3px rgba(255, 91, 102, 0.1);
}
.bulk-actions .icon-button:disabled,
.card-actions .icon-button:disabled {
cursor: not-allowed;
opacity: 1;
color: #727780;
border-color: rgba(114, 119, 128, 0.5);
background: rgba(114, 119, 128, 0.12);
}
.settings-form {
display: block;
padding-bottom: 44px;
}
.settings-panel {
padding: 20px;
display: none;
}
.settings-panel.active {
display: block;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
margin-bottom: 18px;
}
.toggle-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.toggle-row input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.switch-track {
position: relative;
display: inline-flex;
align-items: center;
width: 48px;
height: 26px;
padding: 3px;
border: 1px solid rgba(114, 119, 128, 0.55);
border-radius: 999px;
background: rgba(114, 119, 128, 0.22);
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.switch-thumb {
width: 18px;
height: 18px;
border-radius: 999px;
background: #d4d0c6;
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.35);
transform: translateX(0);
transition: transform 140ms ease, background-color 140ms ease;
}
.toggle-row:hover .switch-track {
border-color: rgba(255, 230, 129, 0.6);
}
.toggle-row input:focus-visible + .switch-track {
box-shadow: 0 0 0 3px rgba(255, 230, 129, 0.18);
}
.toggle-row input:checked + .switch-track {
border-color: var(--gold);
background: var(--gold);
}
.toggle-row input:checked + .switch-track .switch-thumb {
background: #101114;
transform: translateX(22px);
}
.field-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 14px;
}
.field {
display: grid;
gap: 8px;
}
.field.full {
grid-column: 1 / -1;
}
.field.with-button {
min-width: 0;
}
.input-action {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
gap: 8px;
}
.input-action.action-one {
grid-template-columns: minmax(0, 1fr) 42px;
}
.input-action.action-three {
grid-template-columns: minmax(0, 1fr) repeat(3, 42px);
}
.input-action button {
height: 42px;
}
.field label,
.arg-field label {
color: var(--muted);
font-size: 13px;
}
.field input,
.field textarea,
.field select,
.arg-field input,
.arg-field select {
width: 100%;
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.48);
color: var(--text);
border-radius: 6px;
padding: 10px 12px;
}
.field textarea {
min-height: 72px;
resize: vertical;
}
.args-list {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 14px;
}
.arg-field {
display: grid;
gap: 8px;
}
.arg-field.full {
grid-column: 1 / -1;
}
.arg-toggle {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
margin-top: 21px;
color: var(--muted);
}
.arg-toggle input {
width: 18px;
height: 18px;
accent-color: var(--gold);
}
.surreal-install {
padding: 14px 16px;
border: 1px solid rgba(255, 230, 129, 0.18);
border-radius: 8px;
background: rgba(255, 230, 129, 0.04);
}
.install-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 12px;
}
.install-summary div {
display: grid;
gap: 5px;
min-width: 0;
}
.install-summary strong {
color: var(--text);
overflow-wrap: anywhere;
}
.install-summary span {
color: var(--muted);
overflow-wrap: anywhere;
}
.install-action {
display: flex;
align-items: end;
gap: 10px;
flex-wrap: wrap;
}
.install-version,
.install-custom {
display: grid;
gap: 6px;
}
.install-version {
width: 120px;
}
.install-custom {
width: 180px;
}
.install-version span,
.install-custom span {
color: var(--muted);
font-size: 13px;
}
.install-action button {
height: 42px;
}
.log-output {
min-height: 100%;
margin: 0;
padding: 18px;
border: 1px solid #252a33;
background: rgba(0, 0, 0, 0.68);
color: #d7f7df;
border-radius: 8px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 13px;
line-height: 1.55;
}
.editor-overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 28px;
background: rgba(0, 0, 0, 0.68);
z-index: 20;
}
.editor-overlay[hidden] {
display: none;
}
.editor-modal {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(1180px, 92vw);
height: min(760px, 86vh);
border: 1px solid rgba(255, 230, 129, 0.3);
background: #0e1116;
border-radius: 8px;
overflow: hidden;
}
.editor-header,
.editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 16px 18px;
border-bottom: 1px solid #252a33;
}
.editor-header {
position: relative;
padding-right: 64px;
}
.editor-header #editorCloseButton {
position: absolute;
top: 12px;
right: 16px;
width: 34px;
min-width: 34px;
height: 34px;
color: #ff9ca3;
border-color: rgba(255, 91, 102, 0.45);
background: rgba(255, 91, 102, 0.08);
}
.editor-header #editorCloseButton .svg-icon {
width: 16px;
height: 16px;
}
.editor-header #editorCloseButton:hover {
border-color: #ff9ca3;
background: rgba(255, 91, 102, 0.15);
}
.editor-actions {
justify-content: flex-end;
border-top: 1px solid #252a33;
border-bottom: 0;
}
.editor-header h2 {
margin: 0;
color: var(--gold);
}
.editor-header p {
margin: 6px 0 0;
color: var(--muted);
overflow-wrap: anywhere;
}
#configEditor {
width: 100%;
height: 100%;
border: 0;
outline: 0;
resize: none;
padding: 18px;
background: #050609;
color: #e7f7df;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 14px;
line-height: 1.5;
}
@keyframes gumball-ping-green {
0%, 100% {
box-shadow: 0 0 0px rgba(33, 208, 122, 0.2);
}
50% {
box-shadow: 0 0 12px rgba(33, 208, 122, 0.6);
}
}
@keyframes gumball-ping-red {
0%, 100% {
box-shadow: 0 0 0px rgba(255, 91, 102, 0.2);
}
50% {
box-shadow: 0 0 12px rgba(255, 91, 102, 0.62);
}
}
@keyframes gumball-ping-gold {
0%, 100% {
box-shadow: 0 0 0px rgba(255, 230, 129, 0.16);
}
50% {
box-shadow: 0 0 12px rgba(255, 230, 129, 0.55);
}
}
@media (max-width: 1050px) {
.service-grid {
grid-template-columns: 1fr;
}
.field-grid {
grid-template-columns: 1fr;
}
.args-list {
grid-template-columns: 1fr;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -54,19 +54,7 @@ fn default_port() -> u16 {
///
/// If no config file is found, uses default values.
pub fn load() -> Config {
// Try current directory first
let config_path = PathBuf::from("config.toml");
let config_path = if config_path.exists() {
config_path
} else {
// Try executable directory
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("config.toml")))
.filter(|p| p.exists())
.unwrap_or_else(|| PathBuf::from("config.toml"))
};
let config_path = locate_config_path();
match fs::read_to_string(&config_path) {
Ok(contents) => {
@ -89,3 +77,27 @@ pub fn load() -> Config {
}
}
}
fn locate_config_path() -> PathBuf {
let mut candidates = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("config.toml"));
candidates.push(cwd.join("@forge_server").join("config.toml"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.join("config.toml"));
if let Some(parent) = dir.parent() {
candidates.push(parent.join("config.toml"));
candidates.push(parent.join("@forge_server").join("config.toml"));
}
}
}
candidates
.into_iter()
.find(|path| path.exists())
.unwrap_or_else(|| PathBuf::from("config.toml"))
}

View File

@ -36,45 +36,12 @@ Official SurrealDB resources:
- [SurrealDB install page](https://surrealdb.com/install)
- [SurrealDB CLI `start` reference](https://surrealdb.com/docs/surrealdb/cli/start)
Forge also includes helper scripts under `arma/server/surrealdb`:
```powershell
cd arma/server/surrealdb
.\UpdateMe.bat
.\RunMe.bat
```
On Windows, `UpdateMe.bat` is a wrapper around `UpdateSurrealDB.ps1`. By
default it installs or updates to the newest compatible SurrealDB 3.x release
reported by SurrealDB's official version endpoint. You can also pin an exact
release:
```powershell
.\UpdateMe.bat v3.1.2
.\UpdateSurrealDB.ps1 -Version v3.1.2
```
To intentionally install the latest stable SurrealDB release regardless of
major version, run:
```powershell
.\UpdateMe.bat latest
```
The `latest` option prompts for confirmation because a newer SurrealDB major
version can require rebuilding the Forge server extension from source with a
compatible `surrealdb` Rust crate.
`RunMe.bat` is a wrapper around `RunSurrealDB.ps1`, which starts the local
Forge database with the same defaults shown below.
On Linux or macOS:
```bash
cd arma/server/surrealdb
./setup.sh
./run.sh
```
Forge Host includes the recommended local setup path. Open the SurrealDB view
to install or update SurrealDB, configure the bind address and database path,
and start or stop the local database. Enter `3` to install the latest compatible
SurrealDB 3.x release, enter an exact version such as `v3.1.2` to pin a
release, or enter `latest` only after confirming compatibility with the Forge
server extension.
Install SurrealDB with the official method for your platform:

View File

@ -36,45 +36,12 @@ Official SurrealDB resources:
- [SurrealDB install page](https://surrealdb.com/install)
- [SurrealDB CLI `start` reference](https://surrealdb.com/docs/surrealdb/cli/start)
Forge also includes helper scripts under `arma/server/surrealdb`:
```powershell
cd arma/server/surrealdb
.\UpdateMe.bat
.\RunMe.bat
```
On Windows, `UpdateMe.bat` is a wrapper around `UpdateSurrealDB.ps1`. By
default it installs or updates to the newest compatible SurrealDB 3.x release
reported by SurrealDB's official version endpoint. You can also pin an exact
release:
```powershell
.\UpdateMe.bat v3.1.2
.\UpdateSurrealDB.ps1 -Version v3.1.2
```
To intentionally install the latest stable SurrealDB release regardless of
major version, run:
```powershell
.\UpdateMe.bat latest
```
The `latest` option prompts for confirmation because a newer SurrealDB major
version can require rebuilding the Forge server extension from source with a
compatible `surrealdb` Rust crate.
`RunMe.bat` is a wrapper around `RunSurrealDB.ps1`, which starts the local
Forge database with the same defaults shown below.
On Linux or macOS:
```bash
cd arma/server/surrealdb
./setup.sh
./run.sh
```
Forge Host includes the recommended local setup path. Open the SurrealDB view
to install or update SurrealDB, configure the bind address and database path,
and start or stop the local database. Enter `3` to install the latest compatible
SurrealDB 3.x release, enter an exact version such as `v3.1.2` to pin a
release, or enter `latest` only after confirming compatibility with the Forge
server extension.
Install SurrealDB with the official method for your platform:

255
package-lock.json generated
View File

@ -5,7 +5,11 @@
"packages": {
"": {
"name": "forge-webui",
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.7.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"html-minifier-terser": "^7.2.0",
"lightningcss": "^1.29.3",
"postcss": "^8.5.6",
@ -64,6 +68,257 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",

View File

@ -2,6 +2,7 @@
"name": "forge-webui",
"private": true,
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"html-minifier-terser": "^7.2.0",
"lightningcss": "^1.29.3",
"postcss": "^8.5.6",
@ -14,6 +15,11 @@
"docs:sync": "node tools/sync-docus-docs.mjs",
"docs:dev": "npm --prefix docus run dev",
"docs:build": "npm --prefix docus run build",
"host:dev": "cd bin/host/src-tauri && tauri dev",
"host:build": "cd bin/host/src-tauri && tauri build",
"workflow": "node tools/git-workflow.mjs"
},
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.7.1"
}
}