Compare commits

..
18 Commits
Author SHA1 Message Date
MrFastwind 6f3175ac9a feat: add Wine Z: path prefix for Windows platform on Linux host
CI / build (push) Successful in 1m44s
- Add winePath helper that prepends Z: when platform is windows on Linux
- Thread platform through buildModPath, buildAutoArgs, and Start()
- Apply Z: prefix to -config=, -mod=, -profiles=, and %command% binary paths
- Add tests for winePath, buildAutoArgs, and buildModPath with platform param
- Fix formatting across services package with gofmt
- Include all prior bug fixes from this branch
2026-07-24 03:57:51 +02:00
MrFastwind 41c1390972 docs: update PLAN, CODEBASE, README with env vars, stubs, tests, atomic state
PLAN.md:
- Add SERVER_BINARY, SERVER_PARAMS, STEAMCMD_PATH to env vars table
- Add testdata/ to project structure
- Add testing section (backend tests, stubs, frontend tests, make targets)
- Update process start flow with env var overrides

CODEBASE.md:
- Add testdata/ stubs to directory map
- Replace zustand with vitest/testing-library/jsdom in frontend deps
- Update CI to include test steps
- Update Makefile targets description
- Add atomic state machine to ProcessManager description
- Add env overrides and atomic state to design decisions
- Update data flow with env var overrides

README.md:
- Add SERVER_BINARY, SERVER_PARAMS, STEAMCMD_PATH to env vars table
- Remove FRONTEND_DIR (embedded frontend)
- Update Development section with make targets
2026-07-23 22:29:22 +02:00
MrFastwind 930b6cbca6 test(backend): add RPT log output to server stub
- Stub now parses -profiles= arg and writes RPT entries to
  profilesDir/arma3server_x64_<timestamp>.rpt
- RPT includes header (exe timestamp, computer name, OS) and periodic
  entries (MissionEditor, World, Server: Player) on each heartbeat cycle
- New test TestStart_StubWritesRPTLog verifies RPT file creation and
  content via the profiles directory
2026-07-23 21:20:53 +02:00
MrFastwind 04882b3c80 test(backend): enhance stubs with logging, heartbeat, timed exit, and fake downloads
arma3server_x64 stub:
- Logs binary path and all arguments ([STUB] binary=... args=...)
- Prints heartbeat every 5 seconds
- Accepts -t N flag to exit after N seconds (for auto-exit tests)

steamcmd stub:
- Parses +force_install_dir and +workshop_download_item args
- Creates fake mod directories at the expected workshop content path
- Prints fake download progress and success logs

New tests:
- TestStart_StubLogsArgsAndPath: verifies arg logging via log streamer
- TestStart_StubLogsBinaryPath: verifies binary path logging
- TestStart_StubHeartbeatLogs: verifies heartbeat lines appear within timeout
- TestStart_StubAutoExit: verifies -t flag causes server to auto-exit
- TestDownloadMods_StubCreatesModDirs: verifies single mod dir creation
- TestDownloadMods_StubCreatesMultipleModDirs: verifies multi-mod dir creation
- TestDownloadMods_StubLogsAppear: verifies fake download logs on channel
- TestDownloadMods_StubSteamcmdArgsLogged: verifies install_dir logging
2026-07-23 21:07:49 +02:00
MrFastwind 1307bc4740 chore: add backend/server binary to gitignore 2026-07-23 20:56:25 +02:00
MrFastwind 0085ca78fd fix: use absolute paths for dev-deploy directories in Makefile
Relative paths resolved differently from project root vs backend/ CWD,
causing the stub arma3server_x64 to be copied to the wrong location.
Use $(abspath ...) so all paths are absolute and CWD-independent.
2026-07-23 20:53:06 +02:00
MrFastwind 5134a3b261 fix: create dev-deploy directories before copying stubs in make dev 2026-07-23 20:47:06 +02:00
MrFastwind 234116e6a1 test(backend): add tests for SERVER_BINARY, SERVER_PARAMS, STEAMCMD_PATH env vars
- TestStartWithServerBinaryEnv: custom binary name via env
- TestStartWithServerParamsEnv: overridden params via env
- TestServerParamsEnvOverridesSettings: env takes precedence over settings
- TestSteamCmdManager_SteampathEnv: custom steamcmd path via env
2026-07-23 20:39:24 +02:00
MrFastwind 03c3d7bae2 feat: add SERVER_BINARY, SERVER_PARAMS, STEAMCMD_PATH env vars
- ProcessManager.Start() reads SERVER_BINARY and SERVER_PARAMS from env
  with platform-based defaults as fallback
- SteamCmdManager.run() reads STEAMCMD_PATH from env, defaults to
  'steamcmd'
- Makefile: add SERVER_BINARY/SERVER_PARAMS/STEAMCMD_PATH variables,
  copy stubs to dev-deploy in 'make dev', pass env vars to backend
- .gitignore: add bin/
2026-07-23 20:31:38 +02:00
MrFastwind bc23ad7daa test(backend): add process manager and steamcmd tests with stub scripts
- server_process_test.go: 16 tests covering splitArgs, hasArgPrefix,
  buildAutoArgs (config/port/profiles), buildArgs, resolveModPath (name/
  ID/empty), buildModPath, WriteUserconfigFiles, Start/Stop lifecycle,
  percent-command substitution
- steamcmd_test.go: 9 tests covering CheckWorkshopMod (directory/file/
  empty), SteamCmdManager state machine, DownloadMods/UpdateGame
- testdata/: stub arma3server_x64 and steamcmd scripts for safe process
  testing without real binaries
2026-07-23 20:23:31 +02:00
MrFastwind 311cc01ce5 chore(backend): update go.mod after go mod tidy
- Promote golang.org/x/sys from indirect to direct (used by health.go)
2026-07-23 20:15:54 +02:00
MrFastwind 0e58b36bd2 chore: add make test, test-backend, test-frontend targets
- make test runs both backend and frontend tests
- make test-backend runs 'go test ./...'
- make test-frontend runs 'npm test'
2026-07-23 20:15:36 +02:00
MrFastwind d66f8bdd26 ci: add go test and npm test to CI pipeline, remove redundant npm ci
- ci.yml: add 'go test ./...' and 'npm test' steps
- release.yml: remove redundant 'cd frontend && npm ci' (GoReleaser handles it)
2026-07-23 20:15:31 +02:00
MrFastwind d54c46d2ad chore(frontend): remove unused zustand dependency and fix HTML title
- Remove zustand from package.json (was declared but never imported)
- Fix index.html title from 'arma3-frontend' to 'Arma 3 Server Manager'
2026-07-23 20:15:27 +02:00
MrFastwind f4d98830dc refactor(frontend): extract shared fmtSize, remove duplicate ModEntry, use shared ConfigEditor
- Extract fmtSize to src/utils/format.ts (was duplicated in Mods.tsx and Status.tsx)
- Remove duplicate ModEntry interface from ModlistEditor.tsx, import from types
- Refactor pages/ConfigEditor.tsx to use shared components/ConfigEditor.tsx
- Add enabled option to queries with undefined params to prevent premature fetches
2026-07-23 20:15:19 +02:00
MrFastwind 6e628a0bba fix(backend): replace mutex with atomic state machine in ProcessManager and SteamCmdManager
- ProcessManager: atomic.Int32 state enum (idle/starting/running/stopping)
  eliminates race between IsRunning check and Start/Stop
- SteamCmdManager: atomic.Bool with CompareAndSwap for same protection
- Restart() now propagates Stop() errors instead of swallowing them
2026-07-23 20:15:11 +02:00
MrFastwind 9f232b5225 fix(backend): add path validation to DeleteMod and improve GetLog security
- DeleteMod now validates path prefix against SERVERFILE_DIR and MODS_DIR
  before allowing deletion (prevents arbitrary directory deletion)
- GetLog uses strings.ContainsAny for cross-platform path traversal check
2026-07-23 20:15:06 +02:00
MrFastwind d55200886b docs: rewrite PLAN.md and update CODEBASE.md, README.md
- Full rewrite of PLAN.md to reflect current architecture (27 REST + 3 WS endpoints,
  embedded frontend, automation, scheduler, health check, GoReleaser CI)
- Added health.go, mods.go, scheduler.go, robfig/cron dep to CODEBASE.md
- Added Gitea Actions CI/CD section to CODEBASE.md
- Added conventional commits to code style section
- Added missing API routes and embed/ to README.md
2026-07-23 20:14:56 +02:00
46 changed files with 4642 additions and 255 deletions
+4 -1
View File
@@ -16,5 +16,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: cd backend && go build ./...
- run: cd frontend && npm ci && npm run build
- run: mkdir -p backend/embed/dist && cp -r frontend/dist/. backend/embed/dist/
- run: cd backend && go build ./...
- run: cd backend && go test ./...
- run: cd frontend && npm test
-1
View File
@@ -19,7 +19,6 @@ jobs:
node-version: "22"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- run: cd frontend && npm ci
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> 2"
+2
View File
@@ -2,10 +2,12 @@ node_modules/
dist/
frontend/dist/
backend/embed/dist/
bin/
*.exe
*.test
backend/arma3-web-server
backend/server
data/
serverfiles/
+58 -19
View File
@@ -4,20 +4,32 @@
- **Language / runtime**: Go 1.25 (backend), TypeScript 6 + React 19 (frontend)
- **Framework(s)**: Gin v1.12 (HTTP router), gorilla/websocket (WS), TanStack Query v5 (data fetching), Tailwind CSS 4 (styling), Vite 8 (bundler)
- **Build system**: Go toolchain (`go build`), Vite/Rollup for frontend, multi-stage Docker build
- **Test framework**: None detected
- **Test framework**: Go `testing` + Vitest + React Testing Library
## Code Style
- **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) — format: `type(scope): description`
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci`, `perf`
- Scopes: `backend`, `frontend`, `ci`, `docker`, or omit for project-wide changes
- Examples: `feat(backend): add mod cleanup endpoint`, `fix(frontend): resolve race in modlist editor`, `docs: update PLAN.md to reflect current architecture`
## Directory map
```
arma3-web-server/
├── backend/ # Go backend
│ ├── cmd/server/main.go # Entry point, env parsing, dir creation, wiring
│ ├── cmd/server/main.go # Entry point, env parsing, dir creation, startup auto-tasks, wiring
│ ├── embed/
│ │ ├── embed.go # //go:embed dist — embeds frontend into Go binary
│ │ └── dist/ # Pre-built frontend SPA assets
│ ├── internal/
│ │ ├── api/ # Gin HTTP handlers + WebSocket endpoints
│ │ │ ├── router.go # Route registration, Handler struct + New()
│ │ │ ├── settings.go # Settings CRUD + server start/stop/restart + steamcmd
│ │ │ ├── configs.go # .cfg file CRUD handlers
│ │ │ ├── modlists.go # Modlist CRUD + import + check/download-missing/update-all
│ │ │ ├── modlists.go # Modlist CRUD + import + export + check/download-missing/update-all
│ │ │ ├── mods.go # Mod listing, deletion, bulk cleanup
│ │ │ ├── health.go # Comprehensive health check endpoint
│ │ │ └── logs.go # WS streaming (server, steamcmd, rpt) + log file listing
│ │ ├── models/ # Data structs
│ │ │ ├── settings.go # ServerSettings (singleton)
@@ -26,10 +38,15 @@ arma3-web-server/
│ │ ├── settings.go # JSON load/save from data/settings.json
│ │ ├── config_manager.go # .cfg file I/O in $CFG_DIR
│ │ ├── modlist_manager.go # Modlist CRUD against data/modlists/*.json
│ │ ├── modlist_parser.go # HTML Arma Launcher preset parser
│ │ ├── server_process.go # Process lifecycle, arg builder, mod path resolver
│ │ ├── modlist_parser.go # HTML Arma Launcher preset parser + renderer
│ │ ├── mod_manager.go # Workshop + local mod discovery + usage map
│ │ ├── server_process.go # Process lifecycle, arg builder, mod path resolver (atomic state machine)
│ │ ├── steamcmd.go # SteamCMD manager (UpdateGame, DownloadMod, DownloadMods)
│ │ ── log_streamer.go # Pub/sub fan-out for stdout/stderr via channels
│ │ ── scheduler.go # Cron-based scheduled updates
│ │ ├── log_streamer.go # Pub/sub fan-out for stdout/stderr via channels
│ │ └── testdata/ # Stub binaries for testing
│ │ ├── arma3server_x64 # Server stub: logs args, heartbeat, -t N exit, RPT writes
│ │ └── steamcmd # SteamCMD stub: fake downloads, creates mod dirs
│ ├── go.mod / go.sum
│ └── serverfiles/ # Default SERVERFILE_DIR (created at startup)
├── frontend/ # React SPA
@@ -41,24 +58,31 @@ arma3-web-server/
│ │ ├── types/index.ts # ServerSettings, Modlist, ModEntry, ConfigInfo, ServerPaths
│ │ ├── pages/ # 7 route-level page components
│ │ │ ├── Dashboard.tsx # Overview cards (status, configs, modlists)
│ │ │ ├── Settings.tsx # Main settings tab + userconfig tabs + SteamCMD section
│ │ │ ├── Settings.tsx # Main settings tab + userconfig tabs + SteamCMD + automation
│ │ │ ├── Configs.tsx # List/create/duplicate/delete configs
│ │ │ ├── ConfigEditor.tsx # Full-page Monaco editor for a single config
│ │ │ ├── Modlists.tsx # List/create/duplicate/delete + HTML import
│ │ │ ├── ModlistEditor.tsx # Mod list reorder + enable/disable + check/download-missing/update-all
│ │ │ ── Logs.tsx # Tabbed LiveTerminal (Server Console / RPT / SteamCMD) + file browser
│ │ │ ── Mods.tsx # Installed mods table + search + delete + cleanup
│ │ │ ├── Logs.tsx # Tabbed LiveTerminal (Server Console / RPT / SteamCMD) + file browser
│ │ │ └── Status.tsx # Health check / deploy status dashboard
│ │ └── components/
│ │ ├── ConfigEditor.tsx # Monaco editor wrapper
│ │ ├── LiveTerminal.tsx # xterm.js + auto-reconnect WebSocket
│ │ ── Layout.tsx # Sidebar navigation shell
│ │ └── ui/ # Empty
│ │ ── Layout.tsx # Sidebar navigation shell
│ ├── package.json
│ ├── vite.config.ts # Proxy /api + /ws to :8080
│ └── tsconfig*.json
├── data/ # Runtime data (mounted volume in Docker)
│ ├── presets/ # (unused)
│ └── servers/ # (unused)
├── dev-deploy/ # Local development runtime data (git-ignored)
├── .gitea/workflows/
│ ├── ci.yml # Build-only CI (Go + frontend)
│ └── release.yml # GoReleaser-based release on tag push
├── Dockerfile # Multi-stage: Go build -> npm build -> alpine runtime
├── Dockerfile.goreleaser # Single-stage for GoReleaser (pre-built artifacts injected)
├── .goreleaser.yaml # GoReleaser v2 config (Gitea release target)
├── docker-compose.yml # Single service with volume mounts + env vars
└── Makefile # Convenience targets: backend, frontend, build, run, dev, clean
```
@@ -72,7 +96,7 @@ arma3-web-server/
| `github.com/gin-gonic/gin` | framework | v1.12.0 | HTTP routing, middleware, request binding |
| `github.com/gorilla/websocket` | library | v1.5.3 | WebSocket upgrade + message I/O |
| `github.com/google/uuid` | utility | v1.6.0 | UUID generation for modlists |
| `modernc.org/sqlite` | database | v1.53.0 | Declared but **not used** (file-based chosen) |
| `github.com/robfig/cron/v3` | scheduler | v3.0.1 | Cron expression parsing and scheduled task execution |
| `golang.org/x/net` | stdlib | v0.51.0 | HTML parser for modlist import |
### Frontend (npm)
@@ -85,11 +109,14 @@ arma3-web-server/
| `tailwindcss` | styling | ^4.3.2 | Utility-first CSS |
| `@monaco-editor/react` | editor | ^4.7.0 | Monaco code editor for .cfg files |
| `@xterm/xterm` / `@xterm/addon-fit` | terminal | ^6.0.0 / ^0.11.0 | In-browser xterm.js terminal |
| `zustand` | state | ^5.0.14 | Declared but **unused** (no stores exist) |
| `vite` | bundler | ^8.1.1 | Dev server + production build |
| `@vitejs/plugin-react` | tooling | ^6.0.3 | React fast-refresh / JSX transform |
| `typescript` | language | ~6.0.2 | Type checking |
| `oxlint` | linter | ^1.71.0 | Linting |
| `vitest` | test runner | ^4.1.10 | Frontend unit/integration tests |
| `@testing-library/react` | test utility | ^16.3.0 | React component testing |
| `@testing-library/jest-dom` | test matcher | ^7.0.0 | DOM assertion matchers |
| `jsdom` | test env | ^29.1.1 | Browser environment for tests |
## Architecture
@@ -111,7 +138,7 @@ Browser -- REST/WS --> api.Handler --> SettingsManager --> data/settings.j
- **Handler** (api package) -- stateless; receives service pointers via constructor injection. All handlers are methods on Handler.
- ***Manager** (services package) -- stateful structs for each concern. Hold their own `sync.Mutex` for thread safety.
- **LogStreamer** -- pub/sub per named stream (`"server"`, `"steamcmd"`). Subscribe returns a `chan string`; Stream reads from an `io.Reader` (process stdout/stderr) and broadcasts to all subscribers of that key.
- **ProcessManager** -- manages the Arma 3 server child process lifecycle. Builds the command line from settings, resolves mod paths, monitors process exit.
- **ProcessManager** -- manages the Arma 3 server child process lifecycle. Uses `atomic.Int32` state machine (idle/starting/running/stopping) with `CompareAndSwap` for lock-free state transitions. Builds the command line from settings, resolves mod paths, monitors process exit.
### Notable design decisions
1. **No database** -- everything is files: `settings.json`, `*.cfg` in `$CFG_DIR`, modlist JSON files. Backup means copying directories.
@@ -119,7 +146,8 @@ Browser -- REST/WS --> api.Handler --> SettingsManager --> data/settings.j
3. **`%command%` substitution** -- if ServerParameters contains `%command%`, the entire textarea is treated as a shell template, with `%command%` replaced by the binary path and auto-args (`-config=`, `-mod=`, `-profiles=`, `-port=`) appended. Enables Wine wrappers.
4. **Two-tier mod resolution** -- resolveModPath checks `$MODS_DIR/@name` first (manual/symlinked), then falls back to `$SERVERFILE_DIR/steamapps/workshop/content/107410/<id>` for workshop downloads.
5. **SteamCMD async with live streaming** -- all steamcmd operations run in background goroutines, output broadcast to `"steamcmd"` key.
6. **Env-defined paths** -- all dirs set via env vars with hardcoded defaults, resolved to absolute paths at startup.
6. **Env-defined paths** -- all dirs set via env vars with hardcoded defaults, resolved to absolute paths at startup. `SERVER_BINARY`, `SERVER_PARAMS`, `STEAMCMD_PATH` override defaults at runtime.
7. **Atomic state machine** -- ProcessManager and SteamCmdManager use `atomic.Int32`/`atomic.Bool` with `CompareAndSwap` for lock-free state transitions, eliminating race conditions between concurrent Start/Stop calls.
## Data flow (example: Start server)
@@ -130,6 +158,8 @@ User clicks "Start" (Settings.tsx)
--> Handler.StartServer() (settings.go)
--> process.Start() (server_process.go)
--> Load settings.json
--> SERVER_PARAMS env overrides ServerParameters if set
--> SERVER_BINARY env overrides platform default binary name if set
--> If %command% in ServerParameters:
--> Replace %command% with binPath
--> Split into command + args
@@ -149,8 +179,17 @@ User clicks "Start" (Settings.tsx)
```
## CI/CD
- **No CI pipeline** -- no `.github/` or `.gitlab-ci.yml` found
- **No tests** -- zero test files anywhere in the codebase
- **Docker build**: multi-stage Dockerfile at root -- builds Go binary, builds frontend dist, assembles Alpine image with steamcmd, exposes `:8080`, mounts `/data` and `/servers` volumes
- **Deployment**: single docker-compose.yml with one service, volume mounts for persistence
- **Local dev**: Makefile targets -- `make run` starts backend, `make dev` starts both backend + Vite dev server with hot reload
### Gitea Actions (`/.gitea/workflows/`)
- **`ci.yml`** — Triggered on push to any branch or PR. Runs `go build ./...`, `npm ci && npm run build`, `go test ./...`, and `npm test`. Build + test CI.
- **`release.yml`** — Triggered on `v*` tag push. Runs GoReleaser v2 with `release --clean`, which builds Go binaries (linux/amd64 + windows/amd64), builds frontend via `before.hooks`, creates archives, builds + pushes a Docker image using `Dockerfile.goreleaser`, and publishes a Gitea release.
### Docker
- **`Dockerfile`** — Multi-stage build: Go compile → npm build → Debian slim runtime + SteamCMD. Single self-contained image.
- **`Dockerfile.goreleaser`** — Single-stage assembly-only Dockerfile for GoReleaser CI. Expects pre-built artifacts injected by GoReleaser.
### Local dev
- **Makefile targets** — `make run` starts backend, `make dev` starts both backend + Vite dev server with hot reload (copies test stubs to dev-deploy, passes `SERVER_BINARY`/`SERVER_PARAMS`/`STEAMCMD_PATH` env vars). `make test` runs both backend and frontend tests.
-2
View File
@@ -27,7 +27,6 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*
COPY --from=backend /server /usr/local/bin/arma3-web-server
COPY --from=frontend /src/dist /usr/share/arma3-web-server/frontend
EXPOSE 8080
VOLUME ["/data", "/servers"]
@@ -38,7 +37,6 @@ ENV MODS_DIR=/servers/mods
ENV CFG_DIR=/servers/cfg
ENV PROFILES_DIR=/servers/profiles
ENV LISTEN=:8080
ENV FRONTEND_DIR=/usr/share/arma3-web-server/frontend
ENV GIN_MODE=release
ENTRYPOINT ["arma3-web-server"]
-2
View File
@@ -14,7 +14,6 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*
COPY arma3-web-server /usr/local/bin/arma3-web-server
COPY frontend/dist /usr/share/arma3-web-server/frontend
EXPOSE 8080
VOLUME ["/data", "/servers"]
@@ -25,7 +24,6 @@ ENV MODS_DIR=/servers/mods
ENV CFG_DIR=/servers/cfg
ENV PROFILES_DIR=/servers/profiles
ENV LISTEN=:8080
ENV FRONTEND_DIR=/usr/share/arma3-web-server/frontend
ENV GIN_MODE=release
ENTRYPOINT ["arma3-web-server"]
+26 -11
View File
@@ -1,11 +1,16 @@
.PHONY: backend frontend build run clean dev
.PHONY: backend frontend build run clean dev test test-backend test-frontend
# Default paths for dev — override via env
SERVERFILE_DIR ?= ../dev-deploy/serverfiles
MODS_DIR ?= ../dev-deploy/serverfiles/mods
CFG_DIR ?= ../dev-deploy/serverfiles/cfg
PROFILES_DIR ?= ../dev-deploy/serverfiles/profiles
DATA_DIR ?= ../dev-deploy/data
# Default paths for dev — override via env (absolute to avoid CWD mismatches)
SERVERFILE_DIR ?= $(abspath dev-deploy/serverfiles)
MODS_DIR ?= $(abspath dev-deploy/serverfiles/mods)
CFG_DIR ?= $(abspath dev-deploy/serverfiles/cfg)
PROFILES_DIR ?= $(abspath dev-deploy/serverfiles/profiles)
DATA_DIR ?= $(abspath dev-deploy/data)
# Server/steamcmd overrides — set to use stubs in dev
SERVER_BINARY ?= arma3server_x64
SERVER_PARAMS ?= -server -world=empty -loadMissionToMemory -noPause
STEAMCMD_PATH ?= steamcmd
backend:
cd backend && go build -o arma3-web-server ./cmd/server/
@@ -22,14 +27,24 @@ copyfrontend:
build: frontend copyfrontend backend
run:
cd backend && SERVERFILE_DIR=$(SERVERFILE_DIR) MODS_DIR=$(MODS_DIR) CFG_DIR=$(CFG_DIR) PROFILES_DIR=$(PROFILES_DIR) DATA_DIR=$(DATA_DIR) GIN_MODE=debug go run ./cmd/server/
cd backend && SERVERFILE_DIR=$(SERVERFILE_DIR) MODS_DIR=$(MODS_DIR) CFG_DIR=$(CFG_DIR) PROFILES_DIR=$(PROFILES_DIR) DATA_DIR=$(DATA_DIR) SERVER_BINARY=$(SERVER_BINARY) SERVER_PARAMS="$(SERVER_PARAMS)" STEAMCMD_PATH=$(STEAMCMD_PATH) GIN_MODE=debug go run ./cmd/server/
dev:
mkdir -p backend/embed/dist
mkdir -p backend/embed/dist bin $(SERVERFILE_DIR) $(MODS_DIR) $(CFG_DIR) $(PROFILES_DIR) $(DATA_DIR)
test -f backend/embed/dist/dev.txt || touch backend/embed/dist/dev.txt
cd backend && SERVERFILE_DIR=$(SERVERFILE_DIR) MODS_DIR=$(MODS_DIR) CFG_DIR=$(CFG_DIR) PROFILES_DIR=$(PROFILES_DIR) DATA_DIR=$(DATA_DIR) GIN_MODE=debug go run ./cmd/server/ &
cp backend/internal/services/testdata/arma3server_x64 $(SERVERFILE_DIR)/
cp backend/internal/services/testdata/steamcmd bin/
cd backend && PATH=$(CURDIR)/bin:$$PATH SERVERFILE_DIR=$(SERVERFILE_DIR) MODS_DIR=$(MODS_DIR) CFG_DIR=$(CFG_DIR) PROFILES_DIR=$(PROFILES_DIR) DATA_DIR=$(DATA_DIR) SERVER_BINARY=$(SERVER_BINARY) SERVER_PARAMS="$(SERVER_PARAMS)" STEAMCMD_PATH=$(CURDIR)/bin/steamcmd GIN_MODE=debug go run ./cmd/server/ &
cd frontend && npm run dev
clean:
rm -f backend/arma3-web-server
rm -rf frontend/dist backend/embed/dist
rm -rf frontend/dist backend/embed/dist bin
test-backend:
cd backend && go test ./...
test-frontend:
cd frontend && npm test
test: test-backend test-frontend
+184 -53
View File
@@ -10,9 +10,11 @@ A web-based control panel to configure, update, install, and execute a **single*
| Layer | Choice | Rationale |
|-------|--------|-----------|
| **Backend** | Go 1.24+ (`gin`, `gorilla/websocket`) | Single binary, cross-compiles, goroutines for process streaming |
| **Backend** | Go 1.25 (`gin`, `gorilla/websocket`) | Single binary, cross-compiles, goroutines for process streaming |
| **Storage** | File-based (JSON on disk) | No database dependency; settings, configs, and modlists are files |
| **Frontend** | React 19 + TypeScript + Vite | Fast iteration, rich ecosystem |
| **Scheduling** | `robfig/cron/v3` | Cron-based scheduled updates (game + mods) |
| **Frontend** | React 19 + TypeScript 6 + Vite 8 | Fast iteration, rich ecosystem |
| **Data Fetching** | TanStack Query v5 | Server state, caching, polling, mutations |
| **UI Kit** | Tailwind CSS 4 | Dark-theme UI, utility-first styling |
| **Code Editor** | Monaco (VS Code) | Syntax-highlighted config editing |
| **Terminal** | xterm.js | Live log display from server stdout |
@@ -24,49 +26,74 @@ A web-based control panel to configure, update, install, and execute a **single*
```
arma3-web-server/
├── backend/
│ ├── cmd/server/main.go # Entry point
│ ├── cmd/server/main.go # Entry point, env parsing, dir creation, startup auto-tasks
│ ├── embed/
│ │ ├── embed.go # //go:embed dist — embeds frontend into Go binary
│ │ └── dist/ # Pre-built frontend SPA assets
│ ├── internal/
│ │ ├── api/ # HTTP handlers (Gin routes)
│ │ │ ├── router.go # Route registration
│ │ │ ├── settings.go # Server settings + start/stop/restart
│ │ │ ├── configs.go # .cfg file CRUD
│ │ │ ├── modlists.go # Modlist CRUD
│ │ │ ── logs.go # WebSocket handler for live logs
│ │ │ ├── router.go # Route registration, Handler struct + New()
│ │ │ ├── settings.go # Settings CRUD + server start/stop/restart + steamcmd
│ │ │ ├── configs.go # .cfg file CRUD handlers
│ │ │ ├── modlists.go # Modlist CRUD + import + export + check/download-missing/update-all
│ │ │ ── mods.go # Mod listing, deletion, bulk cleanup
│ │ │ ├── health.go # Comprehensive health check endpoint
│ │ │ └── logs.go # WS streaming (server, steamcmd, rpt) + log file listing
│ │ ├── models/ # Data structs
│ │ │ ├── settings.go # ServerSettings (singleton)
│ │ │ └── modlist.go # Modlist + ModEntry
│ │ └── services/ # Business logic
│ │ ├── settings.go # Singleton settings load/save
│ │ │ ├── settings.go # ServerSettings (singleton, 15 fields)
│ │ │ └── modlist.go # Modlist + ModEntry + ModlistListItem
│ │ └── services/ # Business logic layer
│ │ ├── settings.go # JSON load/save from data/settings.json
│ │ ├── config_manager.go # .cfg file I/O in $CFG_DIR
│ │ ├── modlist_manager.go # Modlist CRUD (JSON files)
│ │ ├── server_process.go # Process lifecycle (start/stop/restart)
│ │ ── log_streamer.go # stdout/stderr → WebSocket fan-out
│ │ ├── modlist_manager.go # Modlist CRUD against data/modlists/*.json
│ │ ├── modlist_parser.go # HTML Arma Launcher preset parser + renderer
│ │ ── mod_manager.go # Workshop + local mod discovery + usage map
│ │ ├── server_process.go # Process lifecycle, arg builder, mod path resolver
│ │ ├── steamcmd.go # SteamCMD manager (UpdateGame, DownloadMod, DownloadMods)
│ │ ├── scheduler.go # Cron-based scheduled updates
│ │ ├── log_streamer.go # Pub/sub fan-out for stdout/stderr via channels
│ │ └── testdata/ # Stub binaries for testing
│ │ ├── arma3server_x64 # Server stub: logs args, heartbeat, -t N exit, RPT writes
│ │ └── steamcmd # SteamCMD stub: fake downloads, creates mod dirs
│ ├── go.mod
│ └── go.sum
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Router setup (React Router v7)
│ │ ├── main.tsx # Entry point (QueryClient + BrowserRouter)
│ │ ├── index.css # Tailwind v4 imports
│ │ ├── api/
│ │ │ └── client.ts # Typed fetch wrappers + WS URL builders
│ │ ├── types/
│ │ │ └── index.ts # ServerSettings, Modlist, ModEntry, ConfigInfo, ModInfo, ServerHealth
│ │ ├── pages/
│ │ │ ├── Dashboard.tsx
│ │ │ ├── Settings.tsx
│ │ │ ├── Configs.tsx
│ │ │ ├── ConfigEditor.tsx
│ │ │ ├── Modlists.tsx
│ │ │ ├── ModlistEditor.tsx
│ │ │ ── Logs.tsx
│ │ ├── components/
│ │ │ ── ConfigEditor.tsx # Monaco wrapper
│ │ │ ├── LiveTerminal.tsx # xterm.js wrapper
│ │ ── Layout.tsx # Sidebar nav shell
│ │ ├── api/client.ts # Typed fetch wrapper
│ │ └── types/index.ts # TypeScript types
│ │ │ ├── Dashboard.tsx # Overview cards (status, configs, modlists)
│ │ │ ├── Settings.tsx # Main settings tab + userconfig tabs + SteamCMD + automation
│ │ │ ├── Configs.tsx # List/create/duplicate/delete configs
│ │ │ ├── ConfigEditor.tsx # Full-page Monaco editor for a single config
│ │ │ ├── Modlists.tsx # List/create/duplicate/delete + HTML import
│ │ │ ├── ModlistEditor.tsx # Mod list reorder + enable/disable + check/download-missing/update-all
│ │ │ ── Mods.tsx # Installed mods table + search + delete + cleanup
│ │ │ ├── Logs.tsx # Tabbed LiveTerminal (Server Console / RPT / SteamCMD) + file browser
│ │ │ ── Status.tsx # Health check / deploy status dashboard
│ │ └── components/
│ │ ── ConfigEditor.tsx # Monaco editor wrapper
│ │ ├── LiveTerminal.tsx # xterm.js + auto-reconnect WebSocket
│ │ └── Layout.tsx # Sidebar nav shell
│ ├── package.json
│ └── vite.config.ts
├── data/ # Runtime data
│ └── vite.config.ts # Proxy /api + /ws to :8080
├── data/ # Runtime data (mounted volume in Docker)
│ ├── settings.json # Singleton server settings
│ └── modlists/ # Modlist JSON files (*.json)
├── dev-deploy/ # Local development runtime data (git-ignored)
├── .gitea/workflows/
│ ├── ci.yml # Build-only CI (Go + frontend)
│ └── release.yml # GoReleaser-based release on tag push
├── docker-compose.yml
├── Dockerfile
── Makefile
├── Dockerfile # Multi-stage: Go build -> npm build -> alpine runtime + SteamCMD
── Dockerfile.goreleaser # Single-stage for GoReleaser (pre-built artifacts injected)
├── .goreleaser.yaml # GoReleaser v2 config (Gitea release target)
└── Makefile # Convenience targets: backend, frontend, build, run, dev, clean, test
```
---
@@ -75,13 +102,17 @@ arma3-web-server/
| Env | Purpose | Default |
|-----|---------|---------|
| `SERVERFILE_DIR` | Base dir where `arma3server_x64` binary + `userconfig/` live | `""` (must be set) |
| `MODS_DIR` | Base dir where `@modname` folders are stored | `""` (must be set) |
| `CFG_DIR` | Base dir where `.cfg` config files are stored | `""` (must be set) |
| `PROFILES_DIR` | Server profile/save path (ignored in UI) | `$SERVERFILE_DIR/Profiles` |
| `SERVERFILE_DIR` | Base dir where `arma3server_x64` binary + `userconfig/` live | `./serverfiles` |
| `MODS_DIR` | Base dir where `@modname` folders are stored | `$SERVERFILE_DIR/mods` |
| `CFG_DIR` | Base dir where `.cfg` config files are stored | `$SERVERFILE_DIR/cfg` |
| `PROFILES_DIR` | Server profile/save/log path | `$SERVERFILE_DIR/profiles` |
| `DATA_DIR` | Internal data (settings.json, modlists/) | `./data` |
| `LISTEN` | HTTP listen address | `:8080` |
| `FRONTEND_DIR` | Path to built frontend files | `../frontend/dist` |
| `SERVER_BINARY` | Server binary filename (overrides platform default) | `arma3server_x64` (`.exe` on Windows) |
| `SERVER_PARAMS` | Override server launch parameters (overrides settings.json) | `-server -world=empty -loadMissionToMemory -noPause` |
| `STEAMCMD_PATH` | Path to steamcmd binary | `steamcmd` |
> Note: `FRONTEND_DIR` was removed — the frontend is now embedded into the Go binary via `//go:embed`.
---
@@ -101,6 +132,11 @@ arma3-web-server/
"difficulty_presets": "",
"active_config": "server",
"active_modlist": "uuid-of-modlist",
"auto_update_on_startup": false,
"auto_start_on_startup": false,
"auto_update_mods_on_startup": false,
"was_running": false,
"scheduled_update": "",
"updated_at": "2026-01-01T00:00:00Z"
}
```
@@ -151,11 +187,19 @@ POST /api/server/start
POST /api/server/stop
POST /api/server/restart
GET /api/server/status
GET /api/server/paths # returns configured directory paths
GET /api/server/health # comprehensive health check (binary, paths, disk, mods, steamcmd, frontend)
# SteamCMD
GET /api/server/steamcmd # returns current branch/user/platform
POST /api/server/steamcmd/update-game
POST /api/server/steamcmd/download-mod
GET /api/server/steamcmd/status
# Logs
GET /api/server/logs # list log files (.log, .rpt)
GET /api/server/logs/:file # read log file content
# Configs (.cfg files in $CFG_DIR)
GET /api/configs
POST /api/configs
@@ -172,17 +216,24 @@ PUT /api/modlists/:id
DELETE /api/modlists/:id
POST /api/modlists/:id/duplicate
POST /api/modlists/import (multipart form: file=*.html — parses Arma Launcher HTML preset)
GET /api/modlists/:id/export (returns downloadable Arma Launcher HTML preset)
GET /api/modlists/:id/check (returns mod entries with downloaded: bool)
POST /api/modlists/:id/download-missing
POST /api/modlists/:id/update-all
# Logs
GET /ws/server/logs (WebSocket — live server stream)
GET /ws/steamcmd/logs (WebSocket — live steamcmd stream)
GET /api/server/logs (list log files)
GET /api/server/logs/:file (read log file)
# Mods (installed on disk)
GET /api/mods # combined workshop + local mods with usage info
DELETE /api/mods # delete mod by path (validates path prefix)
POST /api/mods/cleanup # bulk-delete all mods not referenced by any modlist
# WebSocket Endpoints
GET /ws/server/logs # live server process stdout/stderr
GET /ws/steamcmd/logs # live steamcmd output
GET /ws/server/rpt # tail latest .rpt crash dump (250ms polling)
```
**Total: 27 REST endpoints + 3 WebSocket endpoints**
---
## Process Start Flow
@@ -190,24 +241,104 @@ GET /api/server/logs/:file (read log file)
```
User clicks Start
→ Settings loaded from data/settings.json
Args built from server_parameters + -config=<active_config>.cfg
-profiles=$PROFILES_DIR auto-appended (unless already in server_parameters)
-port= derived from ip_port field (unless -port= already in server_parameters)
Mod path built from active modlist (enabled mods → @modname paths)
→ arma3server_x64 spawned via exec.CommandContext
WasRunning set to true, saved to disk
SERVER_PARAMS env overrides ServerParameters if set
SERVER_BINARY env overrides platform default binary name if set
If ServerParameters contains %command%:
→ Replace %command% with binary path (enables Wine wrappers)
→ Split into command + args
→ Append auto-args: -config=, -mod=, -profiles=, -port=
→ exec.CommandContext(ctx, parts[0], parts[1:]..., autoArgs...)
Else:
→ Split server_parameters into args
→ Append auto-args
→ exec.CommandContext(ctx, binPath, args...)
→ cmd.Dir = serverfileDir
→ stdout/stderr piped to LogStreamer fan-out
→ cmd.Start()
→ WebSocket clients receive live output
→ On exit, process reference cleaned up
→ On exit, process reference cleaned up, [SERVER_PROCESS_EXITED] broadcast
→ Frontend polls GET /api/server/status every 3-5s → shows "Running" badge
```
---
## Startup Auto-Tasks
Configured via the Automation section of the Settings UI. All run asynchronously at boot:
| Setting | Behavior |
|---------|----------|
| **Auto-update server on startup** | Runs `steamcmd +app_update` if `steam_user` is set |
| **Auto-update mods on startup** | Downloads workshop updates for every enabled mod in the active modlist |
| **Auto-start server on startup** | Restarts the game server if `was_running` was true when the service last stopped |
### Scheduled Updates
A cron expression in the `scheduled_update` field runs game + mod updates on a schedule (e.g. `"0 4 * * *"` for daily at 4 AM). Uses `robfig/cron/v3`. The scheduled update runs even if the game server is currently running.
---
## Key Design Decisions
1. **File-based storage** — No database. Settings, configs, and modlists are files on disk. Portable, backup-able with simple file copy.
2. **Env-defined paths**`SERVERFILE_DIR`, `MODS_DIR`, `CFG_DIR`, `PROFILES_DIR` are environment variables, not stored in the UI.
3. **Configs are real .cfg files** — Stored in `$CFG_DIR`, directly usable by the Arma 3 server's `-config=` parameter.
4. **Modlist = mod references + enabled state** — The `-mod=` parameter is built at start time by resolving enabled mods. For each mod, `$MODS_DIR/@name` is tried first (manually placed or symlinked); if not found and a Steam Workshop `id` exists, `$SERVERFILE_DIR/steamapps/workshop/content/107410/<id>` is used as fallback.
5. **Single server instance**Only one server at a time. No multi-instance support.
6. **No auth for v1**JWT auth can be added later without breaking the API design.
7. **Docker-first deployment** — Single docker-compose.yml bundles SteamCMD, backend, and frontend serving.
3. **Embedded frontend** — The built React SPA is embedded into the Go binary via `//go:embed`, creating a single self-contained deployment artifact. The `NoRoute` handler serves `index.html` for client-side routing.
4. **Configs are real .cfg files** — Stored in `$CFG_DIR`, directly usable by the Arma 3 server's `-config=` parameter.
5. **Modlist = mod references + enabled state**The `-mod=` parameter is built at start time by resolving enabled mods. Two-tier resolution: `$MODS_DIR/@name` first (manually placed or symlinked); fallback to `$SERVERFILE_DIR/steamapps/workshop/content/107410/<id>` for workshop downloads.
6. **`%command%` substitution** — If `ServerParameters` contains `%command%`, the entire textarea is treated as a shell template with `%command%` replaced by the binary path and auto-args appended. Enables Wine wrappers.
7. **Single server instance** — Only one server at a time. No multi-instance support.
8. **No auth for v1** — JWT auth can be added later without breaking the API design.
9. **Docker-first deployment** — Single docker-compose.yml bundles SteamCMD, backend, and frontend serving. Also supports GoReleaser for automated releases to Gitea.
10. **Crash recovery**`WasRunning` flag persists across restarts. If `auto_start_on_startup` is enabled, the server restarts automatically.
11. **SteamCMD async with live streaming** — All steamcmd operations run in background goroutines, output broadcast to `"steamcmd"` key via LogStreamer pub/sub.
12. **Health endpoint**`/api/server/health` checks binary existence, directory writability, disk usage, mod counts, SteamCMD status, and frontend serving.
---
## Testing
### Backend tests
Run with `make test-backend` or `cd backend && go test ./...`.
Test files in `backend/internal/services/`:
- `settings_test.go` — SettingsManager load/save/defaults
- `config_manager_test.go` — ConfigManager CRUD + path traversal protection
- `modlist_manager_test.go` — ModlistManager CRUD + duplicate
- `modlist_parser_test.go` — HTML preset parser + renderer
- `log_streamer_test.go` — Pub/sub subscribe/unsubscribe/broadcast/stream
- `server_process_test.go` — Process lifecycle, arg building, mod path resolution, env var overrides, stub-based integration tests
- `steamcmd_test.go` — SteamCMD state machine, CheckWorkshopMod, stub-based download tests
### Test stubs
Stub binaries in `backend/internal/services/testdata/` replace real server/steamcmd during tests:
**`arma3server_x64` stub:**
- Logs binary path and all arguments to stdout
- Prints heartbeat every 5 seconds
- Accepts `-t N` to exit after N seconds (for auto-exit and timeout tests)
- Parses `-profiles=<dir>` and writes RPT log entries to `profilesDir/arma3server_x64_*.rpt`
**`steamcmd` stub:**
- Parses `+force_install_dir` and `+workshop_download_item` args
- Creates fake mod directories at the expected workshop content path
- Prints fake download progress and success logs
### Frontend tests
Run with `make test-frontend` or `cd frontend && npm test`.
Uses Vitest + React Testing Library + jsdom:
- `utils/format.test.ts` — fmtSize utility
- `components/Layout.test.tsx` — Sidebar navigation
- `pages/Mods.test.tsx` — Mods page with mods present
### Makefile test targets
| Target | Description |
|--------|-------------|
| `make test` | Run both backend and frontend tests |
| `make test-backend` | Run `go test ./...` in backend |
| `make test-frontend` | Run `vitest run` in frontend |
+24 -7
View File
@@ -47,8 +47,10 @@ All paths are configurable via environment variables:
| `MODS_DIR` | `serverfiles/mods` | Local mod symlinks/copies |
| `CFG_DIR` | `serverfiles/cfg` | Server configuration `.cfg` files |
| `PROFILES_DIR` | `serverfiles/profiles` | Arma 3 profile and log directory |
| `FRONTEND_DIR` | `../frontend/dist` | Built frontend assets |
| `LISTEN` | `:8080` | HTTP listen address |
| `SERVER_BINARY` | `arma3server_x64` | Server binary filename (overrides platform default) |
| `SERVER_PARAMS` | `-server -world=empty ...` | Override server launch parameters |
| `STEAMCMD_PATH` | `steamcmd` | Path to steamcmd binary |
## Automation
@@ -117,11 +119,16 @@ REST API at `/api/*` and WebSocket endpoints at `/ws/*`. Key routes:
| `GET/POST/PUT/DELETE` | `/api/configs` | CRUD for `.cfg` files |
| `GET/POST/PUT/DELETE` | `/api/modlists` | CRUD for modlists |
| `POST` | `/api/modlists/import` | Import HTML workshop list |
| `GET` | `/api/modlists/:id/export` | Export to downloadable HTML preset |
| `GET` | `/api/modlists/:id/check` | Check mod download status |
| `POST` | `/api/modlists/:id/download-missing\|update-all` | Bulk workshop operations |
| `POST` | `/api/server/steamcmd/update-game\|download-mod` | SteamCMD operations |
| `GET` | `/api/mods` | List installed mods (workshop + local) with usage info |
| `DELETE` | `/api/mods` | Delete a mod by path |
| `POST` | `/api/mods/cleanup` | Bulk-delete all orphaned mods |
| `GET` | `/api/server/logs` | List log files |
| `GET` | `/api/server/paths` | Show server file paths |
| `GET` | `/api/server/health` | Comprehensive health check |
| `WS` | `/ws/server/logs\|rpt\|steamcmd/logs` | Live log streaming |
## Project Structure
@@ -129,6 +136,7 @@ REST API at `/api/*` and WebSocket endpoints at `/ws/*`. Key routes:
```
backend/
cmd/server/ Entry point
embed/ Embedded frontend assets (//go:embed)
internal/
api/ HTTP handlers and routes
models/ Data types
@@ -144,14 +152,23 @@ frontend/
## Development
```bash
# Backend
cd backend && go run ./cmd/server
# Start both backend + frontend with hot reload (copies stubs to dev-deploy)
make dev
# Frontend (hot reload)
cd frontend && npm run dev
# Start backend only
make run
# Lint
cd frontend && npm run lint
# Run all tests
make test
# Run backend tests only
make test-backend
# Run frontend tests only
make test-frontend
# Build for production
make build
```
## License
+3 -1
View File
@@ -57,9 +57,11 @@ func main() {
r := gin.Default()
frontendServed := false
subFS, subErr := fs.Sub(embed.Frontend, "dist")
if subErr == nil {
if _, err := subFS.Open("index.html"); err == nil {
frontendServed = true
r.NoRoute(func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
@@ -81,7 +83,7 @@ func main() {
}
}
handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, scheduler, streamer, dataDir, serverfileDir, modsDir, cfgDir, profilesDir)
handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, scheduler, streamer, dataDir, serverfileDir, modsDir, cfgDir, profilesDir, frontendServed)
handler.SetupRoutes(r)
// Startup auto-tasks
+1 -1
View File
@@ -8,6 +8,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/robfig/cron/v3 v3.0.1
golang.org/x/net v0.51.0
golang.org/x/sys v0.44.0
)
require (
@@ -36,7 +37,6 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+1 -3
View File
@@ -76,7 +76,7 @@ func (h *Handler) Health(c *gin.Context) {
Running: h.steamcmd.IsRunning(),
},
Frontend: FrontendHealth{
Served: true,
Served: h.frontendServed,
},
}
@@ -140,8 +140,6 @@ func findServerBinary(serverfileDir string) (string, bool) {
candidates := []string{"arma3server_x64"}
if runtime.GOOS == "windows" {
candidates = append(candidates, "arma3server_x64.exe")
} else {
candidates = append(candidates, "arma3server_x64.exe")
}
for _, name := range candidates {
p := filepath.Join(serverfileDir, name)
+4 -2
View File
@@ -117,7 +117,9 @@ func (h *Handler) StreamRPTLogs(c *gin.Context) {
if currentPath == "" {
continue
}
conn.WriteMessage(websocket.TextMessage, []byte("--- tailing: "+filepath.Base(currentPath)+" ---"))
if err := conn.WriteMessage(websocket.TextMessage, []byte("--- tailing: "+filepath.Base(currentPath)+" ---")); err != nil {
return
}
}
if currentPath == "" {
continue
@@ -170,7 +172,7 @@ func findLatestRPT(dir string) string {
func (h *Handler) GetLog(c *gin.Context) {
filename := filepath.Base(c.Param("file"))
if filename == "" || strings.ContainsRune(c.Param("file"), os.PathSeparator) {
if filename == "" || filename == "." || strings.ContainsAny(filename, "/\\") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
return
}
+14 -1
View File
@@ -2,6 +2,8 @@ package api
import (
"net/http"
"path/filepath"
"strings"
"arma3-web-server/internal/services"
@@ -54,7 +56,18 @@ func (h *Handler) DeleteMod(c *gin.Context) {
return
}
if err := services.RemoveMod(input.Path); err != nil {
absPath, err := filepath.Abs(input.Path)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
if !strings.HasPrefix(absPath, h.serverfileDir) && !strings.HasPrefix(absPath, h.modsDir) {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside allowed directories"})
return
}
if err := services.RemoveMod(absPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+3
View File
@@ -19,6 +19,7 @@ type Handler struct {
modsDir string
cfgDir string
profilesDir string
frontendServed bool
}
func New(
@@ -30,6 +31,7 @@ func New(
scheduler *services.Scheduler,
streamer *services.LogStreamer,
dataDir, serverfileDir, modsDir, cfgDir, profilesDir string,
frontendServed bool,
) *Handler {
return &Handler{
settings: settings,
@@ -44,6 +46,7 @@ func New(
modsDir: modsDir,
cfgDir: cfgDir,
profilesDir: profilesDir,
frontendServed: frontendServed,
}
}
@@ -0,0 +1,160 @@
package services
import (
"os"
"path/filepath"
"testing"
)
func TestConfigManager_ListEmpty(t *testing.T) {
dir := t.TempDir()
cm := NewConfigManager(dir)
configs, err := cm.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(configs) != 0 {
t.Errorf("List() returned %d configs, want 0", len(configs))
}
}
func TestConfigManager_CreateAndGet(t *testing.T) {
dir := t.TempDir()
cm := NewConfigManager(dir)
if err := cm.Create("server", "host = 0.0.0.0"); err != nil {
t.Fatalf("Create() error = %v", err)
}
content, err := cm.Get("server")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if content != "host = 0.0.0.0" {
t.Errorf("Get() = %q, want %q", content, "host = 0.0.0.0")
}
}
func TestConfigManager_CreateDuplicate(t *testing.T) {
dir := t.TempDir()
cm := NewConfigManager(dir)
cm.Create("server", "content")
err := cm.Create("server", "content2")
if err == nil {
t.Error("Create() should fail for existing config")
}
}
func TestConfigManager_Update(t *testing.T) {
dir := t.TempDir()
cm := NewConfigManager(dir)
cm.Create("server", "old")
if err := cm.Update("server", "new"); err != nil {
t.Fatalf("Update() error = %v", err)
}
content, _ := cm.Get("server")
if content != "new" {
t.Errorf("Get() after Update() = %q, want %q", content, "new")
}
}
func TestConfigManager_Delete(t *testing.T) {
dir := t.TempDir()
cm := NewConfigManager(dir)
cm.Create("server", "content")
if err := cm.Delete("server"); err != nil {
t.Fatalf("Delete() error = %v", err)
}
_, err := cm.Get("server")
if err == nil {
t.Error("Get() should fail after Delete()")
}
}
func TestConfigManager_Duplicate(t *testing.T) {
dir := t.TempDir()
cm := NewConfigManager(dir)
cm.Create("server", "host = 0.0.0.0")
if err := cm.Duplicate("server", "backup"); err != nil {
t.Fatalf("Duplicate() error = %v", err)
}
content, _ := cm.Get("backup")
if content != "host = 0.0.0.0" {
t.Errorf("Get(backup) = %q, want %q", content, "host = 0.0.0.0")
}
configs, _ := cm.List()
if len(configs) != 2 {
t.Errorf("List() returned %d configs, want 2", len(configs))
}
}
func TestConfigManager_ListWithMultipleFiles(t *testing.T) {
dir := t.TempDir()
cm := NewConfigManager(dir)
cm.Create("server", "s")
cm.Create("difficulty", "d")
os.WriteFile(filepath.Join(dir, "notacfg.txt"), []byte("txt"), 0644)
configs, err := cm.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(configs) != 2 {
t.Errorf("List() returned %d configs, want 2 (should exclude non-.cfg)", len(configs))
}
}
func TestConfigManager_GetNotFound(t *testing.T) {
dir := t.TempDir()
cm := NewConfigManager(dir)
_, err := cm.Get("nonexistent")
if err == nil {
t.Error("Get() should fail for nonexistent config")
}
}
func TestConfigManager_CreateWithPathTraversal(t *testing.T) {
dir := t.TempDir()
cm := NewConfigManager(dir)
// Attempt to create with path traversal
err := cm.Create("../../etc/passwd", "content")
if err != nil {
t.Fatalf("Create() error = %v", err)
}
// Verify it was sanitized to just "passwd.cfg" in the cfgDir
path := filepath.Join(dir, "passwd.cfg")
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Error("File should be created with sanitized name in cfgDir")
}
}
func TestSanitizeConfigName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"server", "server"},
{"../etc/passwd", "passwd"},
{"../../secret.cfg", "secret"},
{"server.cfg", "server"},
}
for _, tt := range tests {
got := sanitizeConfigName(tt.input)
if got != tt.want {
t.Errorf("sanitizeConfigName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
@@ -0,0 +1,176 @@
package services
import (
"strings"
"sync"
"testing"
"time"
)
func TestLogStreamer_SubscribeUnsubscribe(t *testing.T) {
ls := NewLogStreamer()
ch := ls.Subscribe("server", "client1")
if ch == nil {
t.Fatal("Subscribe() returned nil channel")
}
ls.Unsubscribe("server", "client1")
// Channel should be closed after unsubscribe
select {
case _, ok := <-ch:
if ok {
t.Error("channel should be closed after unsubscribe")
}
case <-time.After(100 * time.Millisecond):
t.Error("channel not closed within timeout")
}
}
func TestLogStreamer_Broadcast(t *testing.T) {
ls := NewLogStreamer()
ch1 := ls.Subscribe("server", "client1")
ch2 := ls.Subscribe("server", "client2")
ls.Broadcast("server", "hello")
for _, ch := range []chan string{ch1, ch2} {
select {
case line := <-ch:
if line != "hello" {
t.Errorf("received %q, want %q", line, "hello")
}
case <-time.After(100 * time.Millisecond):
t.Error("timeout waiting for broadcast")
}
}
}
func TestLogStreamer_BroadcastNonBlocking(t *testing.T) {
ls := NewLogStreamer()
ch := ls.Subscribe("server", "client1")
// Fill the channel (capacity 256)
for i := 0; i < 256; i++ {
ls.Broadcast("server", "line")
}
// This should not block even though channel is full
done := make(chan struct{})
go func() {
ls.Broadcast("server", "dropped")
close(done)
}()
select {
case <-done:
// Good, broadcast didn't block
case <-time.After(100 * time.Millisecond):
t.Error("broadcast blocked on full channel")
}
// Drain all queued messages
for i := 0; i < 256; i++ {
<-ch
}
// Now the next broadcast should succeed
ls.Broadcast("server", "after")
select {
case line := <-ch:
if line != "after" {
t.Errorf("received %q, want %q", line, "after")
}
case <-time.After(100 * time.Millisecond):
t.Error("timeout waiting for broadcast")
}
}
func TestLogStreamer_Stream(t *testing.T) {
ls := NewLogStreamer()
ch := ls.Subscribe("server", "client1")
reader := strings.NewReader("line1\nline2\nline3\n")
go ls.Stream("server", reader, "DONE")
lines := []string{}
for i := 0; i < 4; i++ { // 3 lines + DONE close message
select {
case line := <-ch:
lines = append(lines, line)
case <-time.After(100 * time.Millisecond):
t.Fatalf("timeout waiting for line %d", i+1)
}
}
if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" || lines[3] != "DONE" {
t.Errorf("lines = %v, want [line1 line2 line3 DONE]", lines)
}
}
func TestLogStreamer_UnsubscribeCleansUpEmptyServer(t *testing.T) {
ls := NewLogStreamer()
ls.Subscribe("server", "client1")
ls.Unsubscribe("server", "client1")
// After unsubscribing the last client, the server entry should be cleaned up
ls.mu.RLock()
_, exists := ls.subs["server"]
ls.mu.RUnlock()
if exists {
t.Error("empty server entry should be cleaned up after last unsubscribe")
}
}
func TestLogStreamer_MultipleServers(t *testing.T) {
ls := NewLogStreamer()
chServer := ls.Subscribe("server", "client1")
chSteamcmd := ls.Subscribe("steamcmd", "client1")
ls.Broadcast("server", "server msg")
ls.Broadcast("steamcmd", "steamcmd msg")
select {
case line := <-chServer:
if line != "server msg" {
t.Errorf("server channel received %q, want %q", line, "server msg")
}
case <-time.After(100 * time.Millisecond):
t.Error("timeout on server channel")
}
// SteamCMD channel should not have received the server message
select {
case line := <-chSteamcmd:
if line != "steamcmd msg" {
t.Errorf("steamcmd channel received %q, want %q", line, "steamcmd msg")
}
case <-time.After(100 * time.Millisecond):
t.Error("timeout on steamcmd channel")
}
}
func TestLogStreamer_ConcurrentSubscribeUnsubscribe(t *testing.T) {
ls := NewLogStreamer()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
clientID := "client"
ch := ls.Subscribe("server", clientID)
_ = ch
ls.Unsubscribe("server", clientID)
}(i)
}
wg.Wait()
}
@@ -0,0 +1,147 @@
package services
import (
"testing"
"arma3-web-server/internal/models"
)
func TestModlistManager_CreateAndGet(t *testing.T) {
dir := t.TempDir()
mm := NewModlistManager(dir)
ml, err := mm.Create("My Modlist")
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if ml.Name != "My Modlist" {
t.Errorf("Name = %q, want %q", ml.Name, "My Modlist")
}
if ml.ID == "" {
t.Error("ID should not be empty")
}
if len(ml.Mods) != 0 {
t.Errorf("Mods should be empty, got %d", len(ml.Mods))
}
got, err := mm.Get(ml.ID)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got.Name != "My Modlist" {
t.Errorf("Get().Name = %q, want %q", got.Name, "My Modlist")
}
}
func TestModlistManager_List(t *testing.T) {
dir := t.TempDir()
mm := NewModlistManager(dir)
mm.Create("List A")
mm.Create("List B")
items, err := mm.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(items) != 2 {
t.Errorf("List() returned %d items, want 2", len(items))
}
names := map[string]bool{}
for _, item := range items {
names[item.Name] = true
}
if !names["List A"] || !names["List B"] {
t.Errorf("List() missing expected names: %v", names)
}
}
func TestModlistManager_Update(t *testing.T) {
dir := t.TempDir()
mm := NewModlistManager(dir)
ml, _ := mm.Create("Original")
mods := []models.ModEntry{
{ID: "123", Name: "CBA_A3", Enabled: true},
{ID: "456", Name: "ACE", Enabled: false},
}
updated, err := mm.Update(ml.ID, "Updated", mods)
if err != nil {
t.Fatalf("Update() error = %v", err)
}
if updated.Name != "Updated" {
t.Errorf("Name = %q, want %q", updated.Name, "Updated")
}
if len(updated.Mods) != 2 {
t.Errorf("Mods = %d, want 2", len(updated.Mods))
}
got, _ := mm.Get(ml.ID)
if got.Name != "Updated" {
t.Errorf("Get().Name = %q, want %q", got.Name, "Updated")
}
if got.Mods[0].ID != "123" {
t.Errorf("Get().Mods[0].ID = %q, want %q", got.Mods[0].ID, "123")
}
}
func TestModlistManager_Delete(t *testing.T) {
dir := t.TempDir()
mm := NewModlistManager(dir)
ml, _ := mm.Create("To Delete")
if err := mm.Delete(ml.ID); err != nil {
t.Fatalf("Delete() error = %v", err)
}
_, err := mm.Get(ml.ID)
if err == nil {
t.Error("Get() should fail after Delete()")
}
}
func TestModlistManager_Duplicate(t *testing.T) {
dir := t.TempDir()
mm := NewModlistManager(dir)
orig, _ := mm.Create("Original")
mm.Update(orig.ID, "Original", []models.ModEntry{
{ID: "123", Name: "CBA_A3", Enabled: true},
})
dup, err := mm.Duplicate(orig.ID, "Copy")
if err != nil {
t.Fatalf("Duplicate() error = %v", err)
}
if dup.Name != "Copy" {
t.Errorf("Name = %q, want %q", dup.Name, "Copy")
}
if dup.ID == orig.ID {
t.Error("Duplicate should have different ID")
}
if len(dup.Mods) != 1 {
t.Errorf("Mods = %d, want 1", len(dup.Mods))
}
}
func TestModlistManager_GetNotFound(t *testing.T) {
dir := t.TempDir()
mm := NewModlistManager(dir)
_, err := mm.Get("nonexistent-uuid")
if err == nil {
t.Error("Get() should fail for nonexistent modlist")
}
}
func TestModlistManager_DeleteNotFound(t *testing.T) {
dir := t.TempDir()
mm := NewModlistManager(dir)
err := mm.Delete("nonexistent-uuid")
if err == nil {
t.Error("Delete() should fail for nonexistent modlist")
}
}
@@ -0,0 +1,179 @@
package services
import (
"strings"
"testing"
"arma3-web-server/internal/models"
)
func TestParseModlistHTML_SingleMod(t *testing.T) {
html := `<html><body><table>
<tr data-type="ModContainer">
<td data-type="DisplayName">CBA_A3</td>
<td><span class="from-steam">Steam</span></td>
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
</tr>
</table></body></html>`
_, mods, err := ParseModlistHTML(strings.NewReader(html))
if err != nil {
t.Fatalf("ParseModlistHTML() error = %v", err)
}
if len(mods) != 1 {
t.Fatalf("got %d mods, want 1", len(mods))
}
if mods[0].ID != "450814997" {
t.Errorf("mods[0].ID = %q, want %q", mods[0].ID, "450814997")
}
if mods[0].Name != "CBA_A3" {
t.Errorf("mods[0].Name = %q, want %q", mods[0].Name, "CBA_A3")
}
if !mods[0].Enabled {
t.Error("mods[0].Enabled should be true")
}
}
func TestParseModlistHTML_MultipleMods(t *testing.T) {
html := `<html><body><table>
<tr data-type="ModContainer">
<td data-type="DisplayName">CBA_A3</td>
<td><span>Steam</span></td>
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">ACE3</td>
<td><span>Steam</span></td>
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=463939057">link</a></td>
</tr>
</table></body></html>`
_, mods, err := ParseModlistHTML(strings.NewReader(html))
if err != nil {
t.Fatalf("ParseModlistHTML() error = %v", err)
}
if len(mods) != 2 {
t.Fatalf("got %d mods, want 2", len(mods))
}
if mods[0].Name != "CBA_A3" || mods[1].Name != "ACE3" {
t.Errorf("mods = [%q, %q], want [CBA_A3, ACE3]", mods[0].Name, mods[1].Name)
}
}
func TestParseModlistHTML_Deduplication(t *testing.T) {
html := `<html><body><table>
<tr data-type="ModContainer">
<td data-type="DisplayName">CBA_A3</td>
<td><span>Steam</span></td>
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">CBA_A3</td>
<td><span>Steam</span></td>
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
</tr>
</table></body></html>`
_, mods, _ := ParseModlistHTML(strings.NewReader(html))
if len(mods) != 1 {
t.Errorf("got %d mods, want 1 (deduplication)", len(mods))
}
}
func TestParseModlistHTML_EmptyTable(t *testing.T) {
html := `<html><body><table></table></body></html>`
_, mods, err := ParseModlistHTML(strings.NewReader(html))
if err != nil {
t.Fatalf("ParseModlistHTML() error = %v", err)
}
if len(mods) != 0 {
t.Errorf("got %d mods, want 0", len(mods))
}
}
func TestParseModlistHTML_InvalidHTML(t *testing.T) {
// Go's html parser is lenient and returns empty results for garbage input
_, mods, err := ParseModlistHTML(strings.NewReader("not html at all <>"))
if err != nil {
t.Fatalf("ParseModlistHTML() should not error on lenient parser, got: %v", err)
}
if len(mods) != 0 {
t.Errorf("ParseModlistHTML() returned %d mods for garbage input, want 0", len(mods))
}
}
func TestRenderModlistHTML(t *testing.T) {
mods := []models.ModEntry{
{ID: "450814997", Name: "CBA_A3", Enabled: true},
{ID: "463939057", Name: "ACE3", Enabled: true},
}
output, err := RenderModlistHTML("Test", mods)
if err != nil {
t.Fatalf("RenderModlistHTML() error = %v", err)
}
if !strings.Contains(output, "450814997") {
t.Error("output should contain mod ID 450814997")
}
if !strings.Contains(output, "CBA_A3") {
t.Error("output should contain mod name CBA_A3")
}
if !strings.Contains(output, "463939057") {
t.Error("output should contain mod ID 463939057")
}
if !strings.Contains(output, "<?xml") {
t.Error("output should start with XML declaration")
}
}
func TestRenderModlistHTML_SkipsEmptyIDs(t *testing.T) {
mods := []models.ModEntry{
{ID: "", Name: "No ID", Enabled: true},
{ID: "450814997", Name: "CBA_A3", Enabled: true},
}
output, err := RenderModlistHTML("Test", mods)
if err != nil {
t.Fatalf("RenderModlistHTML() error = %v", err)
}
if strings.Contains(output, "No ID") {
t.Error("output should not contain mod with empty ID")
}
}
func TestFileNameToModlistName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"my_modlist.html", "my modlist"},
{"LibUkraine Final (3).html", "LibUkraine Final (3)"},
{"test-file-name.html", "test file name"},
{"simple.html", "simple"},
}
for _, tt := range tests {
got := FileNameToModlistName(tt.input)
if got != tt.want {
t.Errorf("FileNameToModlistName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestCleanModName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"CBA_A3", "CBA_A3"},
{"mod/name", "mod_name"},
{"mod\\name", "mod_name"},
{" spaces ", "spaces"},
}
for _, tt := range tests {
got := cleanModName(tt.input)
if got != tt.want {
t.Errorf("cleanModName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ func (s *Scheduler) runScheduledUpdate() {
return
}
if sett.SteamUser != "" && sett.SteamUser != "anonymous" {
if sett.SteamUser != "" {
log.Print("scheduler: running gameserver update")
if err := s.steamcmd.UpdateGame(sett.SteamBranch, sett.SteamUser); err != nil {
log.Printf("scheduler: game update failed: %v", err)
+53 -17
View File
@@ -7,12 +7,30 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"arma3-web-server/internal/models"
)
func winePath(path, platform string) string {
if platform == "windows" && runtime.GOOS != "windows" {
return "Z:" + path
}
return path
}
type procState int32
const (
procIdle procState = 0
procStarting procState = 1
procRunning procState = 2
procStopping procState = 3
)
type ProcessManager struct {
serverfileDir string
modsDir string
@@ -23,7 +41,8 @@ type ProcessManager struct {
configs *ConfigManager
streamer *LogStreamer
proc *runningProcess
mu sync.RWMutex
state atomic.Int32
mu sync.Mutex
}
type runningProcess struct {
@@ -54,42 +73,47 @@ func (pm *ProcessManager) ProfilesDir() string {
}
func (pm *ProcessManager) IsRunning() bool {
pm.mu.RLock()
defer pm.mu.RUnlock()
return pm.proc != nil
return pm.state.Load() == int32(procRunning)
}
func (pm *ProcessManager) Start() error {
pm.mu.Lock()
if pm.proc != nil {
pm.mu.Unlock()
return fmt.Errorf("server already running")
if !pm.state.CompareAndSwap(int32(procIdle), int32(procStarting)) {
return fmt.Errorf("server already running or changing state")
}
pm.mu.Unlock()
s, err := pm.settings.Load()
if err != nil {
pm.state.Store(int32(procIdle))
return fmt.Errorf("load settings: %w", err)
}
s.WasRunning = true
if err := pm.settings.Save(s); err != nil {
pm.state.Store(int32(procIdle))
return fmt.Errorf("save was_running: %w", err)
}
binName := "arma3server_x64"
if env := os.Getenv("SERVER_PARAMS"); env != "" {
s.ServerParameters = env
}
binName := os.Getenv("SERVER_BINARY")
if binName == "" {
binName = "arma3server_x64"
if s.Platform == "windows" {
binName = "arma3server_x64.exe"
}
}
binPath := filepath.Join(pm.serverfileDir, binName)
ctx, cancel := context.WithCancel(context.Background())
var cmd *exec.Cmd
if strings.Contains(s.ServerParameters, "%command%") {
full := strings.ReplaceAll(s.ServerParameters, "%command%", binPath)
full := strings.ReplaceAll(s.ServerParameters, "%command%", winePath(binPath, s.Platform))
parts := splitArgs(full)
if len(parts) == 0 {
cancel()
pm.state.Store(int32(procIdle))
return fmt.Errorf("empty command after %%command%% substitution")
}
autoArgs := pm.buildAutoArgs(s)
@@ -102,16 +126,19 @@ func (pm *ProcessManager) Start() error {
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
pm.state.Store(int32(procIdle))
return fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
pm.state.Store(int32(procIdle))
return fmt.Errorf("stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
cancel()
pm.state.Store(int32(procIdle))
return fmt.Errorf("start: %w", err)
}
@@ -119,6 +146,7 @@ func (pm *ProcessManager) Start() error {
pm.mu.Lock()
pm.proc = &runningProcess{cmd: cmd, cancel: cancel, exited: exited}
pm.mu.Unlock()
pm.state.Store(int32(procRunning))
go pm.streamer.Stream("server", stdout, "")
go pm.streamer.Stream("server", stderr, "")
@@ -128,6 +156,7 @@ func (pm *ProcessManager) Start() error {
pm.mu.Lock()
pm.proc = nil
pm.mu.Unlock()
pm.state.Store(int32(procIdle))
close(exited)
pm.streamer.Broadcast("server", "[SERVER_PROCESS_EXITED]")
}()
@@ -136,11 +165,16 @@ func (pm *ProcessManager) Start() error {
}
func (pm *ProcessManager) Stop() error {
if !pm.state.CompareAndSwap(int32(procRunning), int32(procStopping)) {
return fmt.Errorf("server not running")
}
pm.mu.Lock()
rp := pm.proc
pm.mu.Unlock()
if rp == nil {
pm.state.Store(int32(procIdle))
return fmt.Errorf("server not running")
}
@@ -157,7 +191,9 @@ func (pm *ProcessManager) Stop() error {
}
func (pm *ProcessManager) Restart() error {
_ = pm.Stop()
if err := pm.Stop(); err != nil {
return fmt.Errorf("stop: %w", err)
}
return pm.Start()
}
@@ -177,18 +213,18 @@ func (pm *ProcessManager) buildAutoArgs(s *models.ServerSettings) []string {
if s.ActiveConfig != "" {
cfgPath := filepath.Join(pm.cfgDir, s.ActiveConfig+".cfg")
args = append(args, "-config="+cfgPath)
args = append(args, "-config="+winePath(cfgPath, s.Platform))
}
if s.ActiveModlist != "" {
modPath := pm.buildModPath(s.ActiveModlist)
modPath := pm.buildModPath(s.ActiveModlist, s.Platform)
if modPath != "" {
args = append(args, modPath)
}
}
if pm.profilesDir != "" && !hasArgPrefix(args, "-profiles=") {
args = append(args, "-profiles="+pm.profilesDir)
args = append(args, "-profiles="+winePath(pm.profilesDir, s.Platform))
}
if s.IPPort != "" && !hasArgPrefix(args, "-port=") {
@@ -217,7 +253,7 @@ func hasArgPrefix(args []string, prefix string) bool {
const workshopAppID = "107410"
func (pm *ProcessManager) buildModPath(modlistID string) string {
func (pm *ProcessManager) buildModPath(modlistID string, platform string) string {
ml, err := pm.modlists.Get(modlistID)
if err != nil {
return ""
@@ -228,7 +264,7 @@ func (pm *ProcessManager) buildModPath(modlistID string) string {
if !mod.Enabled {
continue
}
parts = append(parts, pm.resolveModPath(mod))
parts = append(parts, winePath(pm.resolveModPath(mod), platform))
}
if len(parts) == 0 {
@@ -0,0 +1,985 @@
package services
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"arma3-web-server/internal/models"
)
func TestSplitArgs(t *testing.T) {
tests := []struct {
name string
raw string
want []string
}{
{"empty", "", nil},
{"single", "-server", []string{"-server"}},
{"multiple", "-server -port=2302 -world=empty", []string{"-server", "-port=2302", "-world=empty"}},
{"extra spaces", " -server -port=2302 ", []string{"-server", "-port=2302"}},
{"tabs", "-server\t-port=2302", []string{"-server", "-port=2302"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitArgs(tt.raw)
if len(got) != len(tt.want) {
t.Fatalf("splitArgs(%q) = %v, want %v", tt.raw, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("splitArgs(%q)[%d] = %q, want %q", tt.raw, i, got[i], tt.want[i])
}
}
})
}
}
func TestHasArgPrefix(t *testing.T) {
args := []string{"-config=foo.cfg", "-mod=@ace", "-port=2302"}
if !hasArgPrefix(args, "-config=") {
t.Error("should find -config=")
}
if !hasArgPrefix(args, "-mod=") {
t.Error("should find -mod=")
}
if hasArgPrefix(args, "-profiles=") {
t.Error("should not find -profiles=")
}
if hasArgPrefix(nil, "-config=") {
t.Error("nil args should return false")
}
}
func TestBuildAutoArgs_ConfigAndPort(t *testing.T) {
dir := t.TempDir()
cfgDir := filepath.Join(dir, "cfg")
profilesDir := filepath.Join(dir, "profiles")
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(cfgDir)
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer)
s := &models.ServerSettings{
ActiveConfig: "server_config",
IPPort: "0.0.0.0:2302",
}
args := pm.buildAutoArgs(s)
// Should contain -config=<cfgDir>/server_config.cfg
foundConfig := false
for _, a := range args {
if strings.HasPrefix(a, "-config=") && strings.HasSuffix(a, "server_config.cfg") {
foundConfig = true
}
}
if !foundConfig {
t.Errorf("expected -config=...server_config.cfg in args, got %v", args)
}
// Should contain -port=2302
foundPort := false
for _, a := range args {
if a == "-port=2302" {
foundPort = true
}
}
if !foundPort {
t.Errorf("expected -port=2302 in args, got %v", args)
}
// Should contain -profiles=
foundProfiles := false
for _, a := range args {
if strings.HasPrefix(a, "-profiles=") {
foundProfiles = true
}
}
if !foundProfiles {
t.Errorf("expected -profiles=... in args, got %v", args)
}
}
func TestBuildAutoArgs_NoConfigNoPort(t *testing.T) {
dir := t.TempDir()
cfgDir := filepath.Join(dir, "cfg")
profilesDir := filepath.Join(dir, "profiles")
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(cfgDir)
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer)
s := &models.ServerSettings{}
args := pm.buildAutoArgs(s)
// Should NOT contain -config= or -port=
for _, a := range args {
if strings.HasPrefix(a, "-config=") {
t.Errorf("unexpected -config= in args: %v", a)
}
if strings.HasPrefix(a, "-port=") {
t.Errorf("unexpected -port= in args: %v", a)
}
}
}
func TestBuildAutoArgs_PortAlreadyInUserArgs(t *testing.T) {
dir := t.TempDir()
cfgDir := filepath.Join(dir, "cfg")
profilesDir := filepath.Join(dir, "profiles")
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(cfgDir)
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer)
s := &models.ServerSettings{
IPPort: "0.0.0.0:2302",
}
args := pm.buildAutoArgs(s)
// -port= should only appear once (from auto args)
portCount := 0
for _, a := range args {
if strings.HasPrefix(a, "-port=") {
portCount++
}
}
if portCount != 1 {
t.Errorf("expected exactly 1 -port= arg, got %d in %v", portCount, args)
}
}
func TestBuildArgs_CombinesParametersAndAutoArgs(t *testing.T) {
dir := t.TempDir()
cfgDir := filepath.Join(dir, "cfg")
profilesDir := filepath.Join(dir, "profiles")
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(cfgDir)
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer)
s := &models.ServerSettings{
ServerParameters: "-server -world=empty -noPause",
ActiveConfig: "main",
IPPort: "0.0.0.0:2400",
}
args := pm.buildArgs(s)
// Should start with user params
if args[0] != "-server" || args[1] != "-world=empty" || args[2] != "-noPause" {
t.Errorf("user params missing from front of args: %v", args)
}
// Should contain auto-generated args
foundConfig := false
foundPort := false
for _, a := range args {
if strings.Contains(a, "main.cfg") {
foundConfig = true
}
if a == "-port=2400" {
foundPort = true
}
}
if !foundConfig {
t.Errorf("expected config arg in %v", args)
}
if !foundPort {
t.Errorf("expected port arg in %v", args)
}
}
func TestResolveModPath_ByModName(t *testing.T) {
dir := t.TempDir()
modsDir := filepath.Join(dir, "mods")
modDir := filepath.Join(modsDir, "@CBA_A3")
os.MkdirAll(modDir, 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
// Existing mod dir → resolved path
got := pm.resolveModPath(models.ModEntry{Name: "CBA_A3"})
if got != modDir {
t.Errorf("resolveModPath(CBA_A3) = %q, want %q", got, modDir)
}
// Non-existing mod dir → fallback to expected path
got = pm.resolveModPath(models.ModEntry{Name: "NonExistent"})
expected := filepath.Join(modsDir, "@NonExistent")
if got != expected {
t.Errorf("resolveModPath(NonExistent) = %q, want %q", got, expected)
}
}
func TestResolveModPath_ByWorkshopID(t *testing.T) {
dir := t.TempDir()
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410", "456789")
os.MkdirAll(workshopDir, 0755)
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
got := pm.resolveModPath(models.ModEntry{ID: "456789"})
if got != workshopDir {
t.Errorf("resolveModPath(ID=456789) = %q, want %q", got, workshopDir)
}
}
func TestResolveModPath_EmptyEntry(t *testing.T) {
dir := t.TempDir()
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
got := pm.resolveModPath(models.ModEntry{})
if got != "" {
t.Errorf("resolveModPath(empty) = %q, want empty", got)
}
}
func TestBuildModPath_MultipleMods(t *testing.T) {
dir := t.TempDir()
modsDir := filepath.Join(dir, "mods")
os.MkdirAll(filepath.Join(modsDir, "@CBA_A3"), 0755)
os.MkdirAll(filepath.Join(modsDir, "@ACE3"), 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
ml, _ := mm.Create("Test List")
mm.Update(ml.ID, "Test List", []models.ModEntry{
{Name: "CBA_A3", Enabled: true},
{Name: "ACE3", Enabled: true},
{Name: "DisabledMod", Enabled: false},
})
got := pm.buildModPath(ml.ID, "")
if !strings.HasPrefix(got, "-mod=") {
t.Errorf("expected -mod= prefix, got %q", got)
}
if !strings.Contains(got, "CBA_A3") || !strings.Contains(got, "ACE3") {
t.Errorf("expected CBA_A3 and ACE3 in mod path, got %q", got)
}
// Disabled mod should not appear
if strings.Contains(got, "DisabledMod") {
t.Errorf("DisabledMod should not be in mod path: %q", got)
}
// Separator should be ;
if !strings.Contains(got, ";") {
t.Errorf("expected ; separator in mod path, got %q", got)
}
}
func TestBuildModPath_EmptyModlist(t *testing.T) {
dir := t.TempDir()
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
got := pm.buildModPath("nonexistent-id", "")
if got != "" {
t.Errorf("buildModPath(nonexistent) = %q, want empty", got)
}
}
func TestWriteUserconfigFiles(t *testing.T) {
dir := t.TempDir()
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
s := &models.ServerSettings{
CBASettings: "force = 1",
AILevelPresets: "preset1",
DifficultyPresets: "difficulty_normal",
}
if err := pm.WriteUserconfigFiles(s); err != nil {
t.Fatalf("WriteUserconfigFiles() error = %v", err)
}
userconfigDir := filepath.Join(dir, "userconfig")
tests := []struct {
filename string
content string
}{
{"cba_settings.sqf", "force = 1"},
{"CfgAILevelPresets.sqf", "preset1"},
{"CfgDifficultyPresets.sqf", "difficulty_normal"},
}
for _, tt := range tests {
path := filepath.Join(userconfigDir, tt.filename)
data, err := os.ReadFile(path)
if err != nil {
t.Errorf("ReadFile(%s) error = %v", tt.filename, err)
continue
}
if string(data) != tt.content {
t.Errorf("%s content = %q, want %q", tt.filename, string(data), tt.content)
}
}
}
func TestWriteUserconfigFiles_EmptyContent(t *testing.T) {
dir := t.TempDir()
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
s := &models.ServerSettings{}
if err := pm.WriteUserconfigFiles(s); err != nil {
t.Fatalf("WriteUserconfigFiles() error = %v", err)
}
// Files should still be created (even if empty)
for _, name := range []string{"cba_settings.sqf", "CfgAILevelPresets.sqf", "CfgDifficultyPresets.sqf"} {
path := filepath.Join(dir, "userconfig", name)
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Errorf("expected %s to exist", name)
}
}
}
func TestWinePath(t *testing.T) {
tests := []struct {
name string
path string
platform string
want string
}{
{"linux platform returns as-is", "/server/cfg/test.cfg", "linux", "/server/cfg/test.cfg"},
{"empty platform returns as-is", "/server/cfg/test.cfg", "", "/server/cfg/test.cfg"},
{"windows platform on linux adds prefix", "/server/cfg/test.cfg", "windows", "Z:/server/cfg/test.cfg"},
{"empty path linux", "", "linux", ""},
{"empty path windows", "", "windows", "Z:"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := winePath(tt.path, tt.platform)
if got != tt.want {
t.Errorf("winePath(%q, %q) = %q, want %q", tt.path, tt.platform, got, tt.want)
}
})
}
}
func TestBuildAutoArgs_WindowsPlatform(t *testing.T) {
dir := t.TempDir()
cfgDir := filepath.Join(dir, "cfg")
profilesDir := filepath.Join(dir, "profiles")
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(cfgDir)
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer)
s := &models.ServerSettings{
Platform: "windows",
ActiveConfig: "server_config",
IPPort: "0.0.0.0:2302",
}
args := pm.buildAutoArgs(s)
foundConfig := false
foundProfiles := false
for _, a := range args {
if strings.HasPrefix(a, "-config=Z:") && strings.HasSuffix(a, "server_config.cfg") {
foundConfig = true
}
if strings.HasPrefix(a, "-profiles=Z:") {
foundProfiles = true
}
}
if !foundConfig {
t.Errorf("expected -config=Z:...server_config.cfg in args, got %v", args)
}
if !foundProfiles {
t.Errorf("expected -profiles=Z:... in args, got %v", args)
}
}
func TestBuildAutoArgs_LinuxPlatform(t *testing.T) {
dir := t.TempDir()
cfgDir := filepath.Join(dir, "cfg")
profilesDir := filepath.Join(dir, "profiles")
modsDir := filepath.Join(dir, "mods")
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(cfgDir)
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer)
s := &models.ServerSettings{
Platform: "linux",
ActiveConfig: "server_config",
IPPort: "0.0.0.0:2302",
}
args := pm.buildAutoArgs(s)
for _, a := range args {
if strings.HasPrefix(a, "-config=Z:") {
t.Errorf("unexpected Z: prefix in args: %v", a)
}
}
}
func TestBuildModPath_WindowsPlatform(t *testing.T) {
dir := t.TempDir()
modsDir := filepath.Join(dir, "mods")
os.MkdirAll(filepath.Join(modsDir, "@CBA_A3"), 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
ml, _ := mm.Create("Test List")
mm.Update(ml.ID, "Test List", []models.ModEntry{
{Name: "CBA_A3", Enabled: true},
})
got := pm.buildModPath(ml.ID, "windows")
if !strings.HasPrefix(got, "-mod=Z:") {
t.Errorf("expected -mod=Z: prefix, got %q", got)
}
}
func TestBuildModPath_LinuxPlatform(t *testing.T) {
dir := t.TempDir()
modsDir := filepath.Join(dir, "mods")
os.MkdirAll(filepath.Join(modsDir, "@CBA_A3"), 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
ml, _ := mm.Create("Test List")
mm.Update(ml.ID, "Test List", []models.ModEntry{
{Name: "CBA_A3", Enabled: true},
})
got := pm.buildModPath(ml.ID, "linux")
if strings.Contains(got, "Z:") {
t.Errorf("unexpected Z: prefix in args: %q", got)
}
}
func TestStartAndStopWithStub(t *testing.T) {
dir := t.TempDir()
serverfileDir := filepath.Join(dir, "server")
modsDir := filepath.Join(dir, "mods")
cfgDir := filepath.Join(dir, "cfg")
profilesDir := filepath.Join(dir, "profiles")
os.MkdirAll(serverfileDir, 0755)
// Copy stub into serverfileDir so exec finds it
stubSrc, _ := os.ReadFile("testdata/arma3server_x64")
os.WriteFile(filepath.Join(serverfileDir, "arma3server_x64"), stubSrc, 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(cfgDir)
streamer := NewLogStreamer()
pm := NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer)
s, _ := sm.Load()
s.ServerParameters = "-server -world=empty"
sm.Save(s)
if err := pm.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
if !pm.IsRunning() {
t.Fatal("IsRunning() should be true after Start()")
}
// Second Start should fail (already running)
if err := pm.Start(); err == nil {
t.Error("second Start() should fail")
}
if err := pm.Stop(); err != nil {
t.Fatalf("Stop() error = %v", err)
}
// Give goroutine time to update state
time.Sleep(100 * time.Millisecond)
if pm.IsRunning() {
t.Error("IsRunning() should be false after Stop()")
}
// Second Stop should fail
if err := pm.Stop(); err == nil {
t.Error("second Stop() should fail")
}
}
func TestStartWithPercentCommand(t *testing.T) {
dir := t.TempDir()
serverfileDir := filepath.Join(dir, "server")
modsDir := filepath.Join(dir, "mods")
cfgDir := filepath.Join(dir, "cfg")
profilesDir := filepath.Join(dir, "profiles")
os.MkdirAll(serverfileDir, 0755)
// Copy stub
stubSrc, _ := os.ReadFile("testdata/arma3server_x64")
stubPath := filepath.Join(serverfileDir, "arma3server_x64")
os.WriteFile(stubPath, stubSrc, 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(cfgDir)
streamer := NewLogStreamer()
pm := NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer)
s, _ := sm.Load()
s.ServerParameters = "%command% -world=empty"
sm.Save(s)
if err := pm.Start(); err != nil {
t.Fatalf("Start() with %%command%% error = %v", err)
}
if !pm.IsRunning() {
t.Fatal("IsRunning() should be true")
}
pm.Stop()
time.Sleep(100 * time.Millisecond)
}
func TestStartWithEmptyCommand(t *testing.T) {
dir := t.TempDir()
serverfileDir := filepath.Join(dir, "server")
os.MkdirAll(serverfileDir, 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
s, _ := sm.Load()
s.ServerParameters = ""
sm.Save(s)
// Empty params without %command% should still work (starts with just the binary)
// But if we use %command% with nothing after it, it should fail
s.ServerParameters = "%command%"
sm.Save(s)
err := pm.Start()
if err == nil {
t.Error("Start() with percent-command-percent only (no other args) should fail")
pm.Stop()
}
}
func TestStartWithServerBinaryEnv(t *testing.T) {
dir := t.TempDir()
serverfileDir := filepath.Join(dir, "server")
os.MkdirAll(serverfileDir, 0755)
stubSrc, _ := os.ReadFile("testdata/arma3server_x64")
customBin := "my-custom-server"
os.WriteFile(filepath.Join(serverfileDir, customBin), stubSrc, 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
s, _ := sm.Load()
s.ServerParameters = "-server -world=empty"
sm.Save(s)
t.Setenv("SERVER_BINARY", customBin)
if err := pm.Start(); err != nil {
t.Fatalf("Start() with SERVER_BINARY env error = %v", err)
}
if !pm.IsRunning() {
t.Fatal("IsRunning() should be true")
}
pm.Stop()
time.Sleep(100 * time.Millisecond)
}
func TestStartWithServerParamsEnv(t *testing.T) {
dir := t.TempDir()
serverfileDir := filepath.Join(dir, "server")
os.MkdirAll(serverfileDir, 0755)
stubSrc, _ := os.ReadFile("testdata/arma3server_x64")
os.WriteFile(filepath.Join(serverfileDir, "arma3server_x64"), stubSrc, 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
s, _ := sm.Load()
s.ServerParameters = "-server -world=empty"
sm.Save(s)
t.Setenv("SERVER_PARAMS", "%command% -custom-param")
if err := pm.Start(); err != nil {
t.Fatalf("Start() with SERVER_PARAMS env error = %v", err)
}
if !pm.IsRunning() {
t.Fatal("IsRunning() should be true")
}
pm.Stop()
time.Sleep(100 * time.Millisecond)
}
func TestServerParamsEnvOverridesSettings(t *testing.T) {
dir := t.TempDir()
serverfileDir := filepath.Join(dir, "server")
os.MkdirAll(serverfileDir, 0755)
stubSrc, _ := os.ReadFile("testdata/arma3server_x64")
os.WriteFile(filepath.Join(serverfileDir, "arma3server_x64"), stubSrc, 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
s, _ := sm.Load()
s.ServerParameters = "-server -world=empty"
sm.Save(s)
// Without SERVER_PARAMS override, Start() would use settings value (no %command%).
// With override, it uses %command% substitution.
t.Setenv("SERVER_PARAMS", "%command% -test-override")
if err := pm.Start(); err != nil {
t.Fatalf("Start() error = %v (SERVER_PARAMS override not applied?)", err)
}
pm.Stop()
time.Sleep(100 * time.Millisecond)
}
// helper: sets up a ProcessManager with the stub and optional server params
func setupStubPM(t *testing.T, serverParams string) (*ProcessManager, *LogStreamer) {
t.Helper()
dir := t.TempDir()
serverfileDir := filepath.Join(dir, "server")
os.MkdirAll(serverfileDir, 0755)
stubSrc, _ := os.ReadFile("testdata/arma3server_x64")
os.WriteFile(filepath.Join(serverfileDir, "arma3server_x64"), stubSrc, 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(filepath.Join(dir, "cfg"))
streamer := NewLogStreamer()
pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer)
if serverParams != "" {
s, _ := sm.Load()
s.ServerParameters = serverParams
sm.Save(s)
}
return pm, streamer
}
// readLines reads up to n lines from ch within timeout
func readLines(ch chan string, n int, timeout time.Duration) []string {
var lines []string
deadline := time.Now().Add(timeout)
for len(lines) < n {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
select {
case line, ok := <-ch:
if !ok {
return lines
}
lines = append(lines, line)
case <-time.After(remaining):
return lines
}
}
return lines
}
func containsAny(s string, substrs ...string) bool {
for _, sub := range substrs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
func TestStart_StubLogsArgsAndPath(t *testing.T) {
pm, streamer := setupStubPM(t, "-server -port=2402")
ch := streamer.Subscribe("server", "test-args")
defer streamer.Unsubscribe("server", "test-args")
if err := pm.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
lines := readLines(ch, 10, 3*time.Second)
foundArgsLog := false
for _, line := range lines {
if strings.Contains(line, "[STUB]") && strings.Contains(line, "args=") {
foundArgsLog = true
break
}
}
if !foundArgsLog {
t.Errorf("expected [STUB] args= log line, got %v", lines)
}
pm.Stop()
time.Sleep(100 * time.Millisecond)
}
func TestStart_StubLogsBinaryPath(t *testing.T) {
pm, streamer := setupStubPM(t, "-server -world=empty")
ch := streamer.Subscribe("server", "test-path")
defer streamer.Unsubscribe("server", "test-path")
if err := pm.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
lines := readLines(ch, 10, 3*time.Second)
foundBinaryLog := false
for _, line := range lines {
if strings.Contains(line, "[STUB]") && strings.Contains(line, "binary=") {
foundBinaryLog = true
break
}
}
if !foundBinaryLog {
t.Errorf("expected [STUB] binary= log line, got %v", lines)
}
pm.Stop()
time.Sleep(100 * time.Millisecond)
}
func TestStart_StubHeartbeatLogs(t *testing.T) {
pm, streamer := setupStubPM(t, "-t 12")
ch := streamer.Subscribe("server", "test-heartbeat")
defer streamer.Unsubscribe("server", "test-heartbeat")
if err := pm.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
// Collect lines for up to 10s, looking for a heartbeat
lines := readLines(ch, 50, 10*time.Second)
foundHeartbeat := false
for _, line := range lines {
if strings.Contains(line, "[STUB]") && strings.Contains(line, "heartbeat") {
foundHeartbeat = true
break
}
}
if !foundHeartbeat {
t.Errorf("expected [STUB] heartbeat log line within 10s, got %v", lines)
}
pm.Stop()
time.Sleep(100 * time.Millisecond)
}
func TestStart_StubAutoExit(t *testing.T) {
pm, streamer := setupStubPM(t, "-t 2")
streamer.Subscribe("server", "test-autoexit")
defer streamer.Unsubscribe("server", "test-autoexit")
if err := pm.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
if !pm.IsRunning() {
t.Fatal("IsRunning() should be true immediately after Start()")
}
// Wait for stub to exit (2s + margin)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if !pm.IsRunning() {
break
}
time.Sleep(200 * time.Millisecond)
}
if pm.IsRunning() {
t.Error("IsRunning() should be false after stub auto-exited (timeout 2s)")
}
}
func TestStart_StubWritesRPTLog(t *testing.T) {
dir := t.TempDir()
serverfileDir := filepath.Join(dir, "server")
profilesDir := filepath.Join(dir, "profiles")
modsDir := filepath.Join(dir, "mods")
cfgDir := filepath.Join(dir, "cfg")
os.MkdirAll(serverfileDir, 0755)
stubSrc, _ := os.ReadFile("testdata/arma3server_x64")
os.WriteFile(filepath.Join(serverfileDir, "arma3server_x64"), stubSrc, 0755)
sm := NewSettingsManager(dir)
mm := NewModlistManager(filepath.Join(dir, "modlists"))
cm := NewConfigManager(cfgDir)
streamer := NewLogStreamer()
pm := NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer)
s, _ := sm.Load()
s.ServerParameters = "-t 7"
sm.Save(s)
if err := pm.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
// Wait for the stub to write RPT entries (heartbeat at ~5s)
deadline := time.Now().Add(8 * time.Second)
var rptPath string
for time.Now().Before(deadline) {
matches, _ := filepath.Glob(filepath.Join(profilesDir, "arma3server_x64_*.rpt"))
if len(matches) > 0 {
rptPath = matches[0]
break
}
time.Sleep(200 * time.Millisecond)
}
if rptPath == "" {
pm.Stop()
t.Fatal("RPT file not found in profiles dir")
}
// Wait for heartbeat entries to be written (heartbeat at ~5s)
deadline2 := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline2) {
data, _ := os.ReadFile(rptPath)
if strings.Contains(string(data), "MissionEditor") {
break
}
time.Sleep(500 * time.Millisecond)
}
data, err := os.ReadFile(rptPath)
if err != nil {
pm.Stop()
t.Fatalf("ReadFile(%s) error = %v", rptPath, err)
}
content := string(data)
if !strings.Contains(content, "RPT log started") {
t.Errorf("RPT missing header, got:\n%s", content)
}
if !strings.Contains(content, "MissionEditor") {
t.Errorf("RPT missing MissionEditor entry, got:\n%s", content)
}
if !strings.Contains(content, "Server: Player") {
t.Errorf("RPT missing Server: Player entry, got:\n%s", content)
}
pm.Stop()
time.Sleep(100 * time.Millisecond)
}
+125
View File
@@ -0,0 +1,125 @@
package services
import (
"os"
"path/filepath"
"testing"
)
func TestSettingsManager_LoadDefaults(t *testing.T) {
dir := t.TempDir()
sm := NewSettingsManager(dir)
s, err := sm.Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if s.IPPort != "0.0.0.0:2302" {
t.Errorf("IPPort = %q, want %q", s.IPPort, "0.0.0.0:2302")
}
if s.ServerParameters != "-server -world=empty -loadMissionToMemory -noPause" {
t.Errorf("ServerParameters = %q", s.ServerParameters)
}
if s.SteamBranch != "stable" {
t.Errorf("SteamBranch = %q, want %q", s.SteamBranch, "stable")
}
if s.SteamUser != "anonymous" {
t.Errorf("SteamUser = %q, want %q", s.SteamUser, "anonymous")
}
if s.Platform != "linux" {
t.Errorf("Platform = %q, want %q", s.Platform, "linux")
}
if s.AutoUpdateOnStartup {
t.Error("AutoUpdateOnStartup should be false by default")
}
if s.WasRunning {
t.Error("WasRunning should be false by default")
}
}
func TestSettingsManager_SaveLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
sm := NewSettingsManager(dir)
s, err := sm.Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
s.IPPort = "192.168.1.100:2400"
s.SteamUser = "myuser"
s.Platform = "windows"
s.CBASettings = "some_cba_content"
s.AutoUpdateOnStartup = true
s.WasRunning = true
s.ScheduledUpdate = "0 4 * * *"
if err := sm.Save(s); err != nil {
t.Fatalf("Save() error = %v", err)
}
// Verify file exists
if _, err := os.Stat(filepath.Join(dir, "settings.json")); os.IsNotExist(err) {
t.Fatal("settings.json not created")
}
// Load in a new manager to verify persistence
sm2 := NewSettingsManager(dir)
s2, err := sm2.Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if s2.IPPort != "192.168.1.100:2400" {
t.Errorf("IPPort = %q, want %q", s2.IPPort, "192.168.1.100:2400")
}
if s2.SteamUser != "myuser" {
t.Errorf("SteamUser = %q, want %q", s2.SteamUser, "myuser")
}
if s2.Platform != "windows" {
t.Errorf("Platform = %q, want %q", s2.Platform, "windows")
}
if s2.CBASettings != "some_cba_content" {
t.Errorf("CBASettings = %q, want %q", s2.CBASettings, "some_cba_content")
}
if !s2.AutoUpdateOnStartup {
t.Error("AutoUpdateOnStartup should be true")
}
if !s2.WasRunning {
t.Error("WasRunning should be true")
}
if s2.ScheduledUpdate != "0 4 * * *" {
t.Errorf("ScheduledUpdate = %q, want %q", s2.ScheduledUpdate, "0 4 * * *")
}
}
func TestSettingsManager_LoadMissingFile(t *testing.T) {
dir := t.TempDir()
sm := NewSettingsManager(dir)
s, err := sm.Load()
if err != nil {
t.Fatalf("Load() error = %v, want nil", err)
}
if s == nil {
t.Fatal("Load() returned nil settings")
}
if s.IPPort != "0.0.0.0:2302" {
t.Errorf("default IPPort = %q, want %q", s.IPPort, "0.0.0.0:2302")
}
}
func TestSettingsManager_SaveUpdatesTimestamp(t *testing.T) {
dir := t.TempDir()
sm := NewSettingsManager(dir)
s, _ := sm.Load()
s.SteamUser = "test"
if err := sm.Save(s); err != nil {
t.Fatalf("Save() error = %v", err)
}
s2, _ := sm.Load()
if s2.UpdatedAt.IsZero() {
t.Error("UpdatedAt should be set after Save()")
}
}
+14 -21
View File
@@ -6,7 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sync"
"sync/atomic"
"time"
)
@@ -24,9 +24,8 @@ const arma3AppID = "233780"
type SteamCmdManager struct {
serverfileDir string
streamer *LogStreamer
mu sync.Mutex
running atomic.Bool
cancel context.CancelFunc
running bool
}
func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager {
@@ -37,18 +36,13 @@ func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdMa
}
func (s *SteamCmdManager) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
return s.running.Load()
}
func (s *SteamCmdManager) UpdateGame(branch, user string) error {
s.mu.Lock()
if s.running {
s.mu.Unlock()
if !s.running.CompareAndSwap(false, true) {
return fmt.Errorf("steamcmd already running")
}
s.mu.Unlock()
args := []string{
"+force_install_dir", s.serverfileDir,
@@ -68,12 +62,9 @@ func (s *SteamCmdManager) DownloadMod(modID string) error {
}
func (s *SteamCmdManager) DownloadMods(modIDs []string) error {
s.mu.Lock()
if s.running {
s.mu.Unlock()
if !s.running.CompareAndSwap(false, true) {
return fmt.Errorf("steamcmd already running")
}
s.mu.Unlock()
if len(modIDs) == 0 {
return fmt.Errorf("no mod ids provided")
@@ -99,38 +90,40 @@ func (s *SteamCmdManager) run(label string, args []string) error {
s.streamer.Broadcast("steamcmd", "[STEAMCMD] "+label+" starting...")
cmd := exec.CommandContext(ctx, "steamcmd", args...)
steamcmdPath := os.Getenv("STEAMCMD_PATH")
if steamcmdPath == "" {
steamcmdPath = "steamcmd"
}
cmd := exec.CommandContext(ctx, steamcmdPath, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
s.running.Store(false)
return fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
s.running.Store(false)
return fmt.Errorf("stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
cancel()
s.running.Store(false)
return fmt.Errorf("start steamcmd: %w", err)
}
s.mu.Lock()
s.running = true
s.cancel = cancel
s.mu.Unlock()
go s.streamer.Stream("steamcmd", stdout, "")
go s.streamer.Stream("steamcmd", stderr, "")
go func() {
err := cmd.Wait()
s.mu.Lock()
s.running = false
s.running.Store(false)
s.cancel = nil
s.mu.Unlock()
if err == nil {
s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished")
} else {
+332
View File
@@ -0,0 +1,332 @@
package services
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCheckWorkshopMod(t *testing.T) {
dir := t.TempDir()
// Existing workshop mod
modDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410", "123456")
os.MkdirAll(modDir, 0755)
if !CheckWorkshopMod(dir, "123456") {
t.Error("CheckWorkshopMod should return true for existing mod")
}
// Non-existing mod
if CheckWorkshopMod(dir, "999999") {
t.Error("CheckWorkshopMod should return false for non-existing mod")
}
// Empty mod ID
if CheckWorkshopMod(dir, "") {
t.Error("CheckWorkshopMod should return false for empty mod ID")
}
}
func TestCheckWorkshopMod_IsFile(t *testing.T) {
dir := t.TempDir()
// Create a file instead of directory at the expected path
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410")
os.MkdirAll(workshopDir, 0755)
os.WriteFile(filepath.Join(workshopDir, "111111"), []byte("not a dir"), 0644)
if CheckWorkshopMod(dir, "111111") {
t.Error("CheckWorkshopMod should return false for file (not directory)")
}
}
func TestSteamCmdManager_IsRunning(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
if sm.IsRunning() {
t.Error("IsRunning() should be false initially")
}
}
func TestSteamCmdManager_DoubleStart(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
// First DownloadMods sets running=true. If the binary exists, the goroutine
// handles cleanup. If it doesn't, running is reset synchronously.
sm.DownloadMods([]string{"123456"})
// Second call while running should get "already running"
err2 := sm.DownloadMods([]string{"789012"})
if err2 == nil || err2.Error() != "steamcmd already running" {
t.Logf("second DownloadMods: %v (may vary based on timing)", err2)
}
waitForNotRunning(t, sm, 30*time.Second)
}
func waitForNotRunning(t *testing.T, sm *SteamCmdManager, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !sm.IsRunning() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Errorf("IsRunning() still true after %v", timeout)
}
func TestSteamCmdManager_StopResetsFlag(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
// After run completes (success or failure), running flag should be reset
sm.DownloadMods([]string{"123"})
waitForNotRunning(t, sm, 30*time.Second)
}
func TestSteamCmdManager_UpdateGameArgs(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
err := sm.UpdateGame("stable", "anonymous")
if err != nil {
t.Logf("UpdateGame returned (expected): %v", err)
}
waitForNotRunning(t, sm, 30*time.Second)
}
func TestSteamCmdManager_UpdateGameBeta(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
// Test with a beta branch
err := sm.UpdateGame("creatordlc", "anonymous")
if err != nil {
t.Logf("UpdateGame(creatordlc) returned (expected): %v", err)
}
waitForNotRunning(t, sm, 30*time.Second)
}
func TestSteamCmdManager_DownloadModEmpty(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
err := sm.DownloadMods([]string{})
if err == nil {
t.Error("DownloadMods with empty list should return error")
}
}
func TestSteamCmdManager_DownloadModSingle(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
err := sm.DownloadMod("123456")
// steamcmd may or may not exist; either way the call should complete
if err != nil {
t.Logf("DownloadMod returned (expected): %v", err)
}
waitForNotRunning(t, sm, 30*time.Second)
}
func TestNewSteamCmdManager(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
if sm.serverfileDir != dir {
t.Errorf("serverfileDir = %q, want %q", sm.serverfileDir, dir)
}
if sm.streamer != streamer {
t.Error("streamer should be set")
}
if sm.IsRunning() {
t.Error("new manager should not be running")
}
}
func TestSteamCmdManager_SteampathEnv(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
// Point STEAMCMD_PATH at the stub so it doesn't need the real binary
stubPath, _ := filepath.Abs("testdata/steamcmd")
t.Setenv("STEAMCMD_PATH", stubPath)
err := sm.DownloadMod("123456")
if err != nil {
t.Fatalf("DownloadMod with STEAMCMD_PATH env error = %v", err)
}
waitForNotRunning(t, sm, 30*time.Second)
}
func TestDownloadMods_StubCreatesModDirs(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
stubPath, _ := filepath.Abs("testdata/steamcmd")
t.Setenv("STEAMCMD_PATH", stubPath)
modID := "999999"
err := sm.DownloadMod(modID)
if err != nil {
t.Fatalf("DownloadMod error = %v", err)
}
waitForNotRunning(t, sm, 30*time.Second)
// Stub should have created the workshop mod directory
modDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410", modID)
fi, err := os.Stat(modDir)
if err != nil {
t.Fatalf("mod directory should exist after stub download: %v", err)
}
if !fi.IsDir() {
t.Errorf("mod path %q should be a directory", modDir)
}
}
func TestDownloadMods_StubCreatesMultipleModDirs(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
stubPath, _ := filepath.Abs("testdata/steamcmd")
t.Setenv("STEAMCMD_PATH", stubPath)
mods := []string{"111111", "222222", "333333"}
err := sm.DownloadMods(mods)
if err != nil {
t.Fatalf("DownloadMods error = %v", err)
}
waitForNotRunning(t, sm, 30*time.Second)
for _, modID := range mods {
modDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410", modID)
if _, err := os.Stat(modDir); os.IsNotExist(err) {
t.Errorf("mod directory for %s should exist", modID)
}
}
}
func TestDownloadMods_StubLogsAppear(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
stubPath, _ := filepath.Abs("testdata/steamcmd")
t.Setenv("STEAMCMD_PATH", stubPath)
ch := streamer.Subscribe("steamcmd", "test-dl-logs")
defer streamer.Unsubscribe("steamcmd", "test-dl-logs")
err := sm.DownloadMod("444444")
if err != nil {
t.Fatalf("DownloadMod error = %v", err)
}
// Read lines with generous timeout (stub may take a moment)
lines := readSteamcmdLines(ch, 20, 5*time.Second)
foundDownloading := false
foundSuccess := false
for _, line := range lines {
if strings.Contains(line, "Downloading") && strings.Contains(line, "444444") {
foundDownloading = true
}
if strings.Contains(line, "Success") {
foundSuccess = true
}
}
if !foundDownloading {
t.Errorf("expected 'Downloading 444444' log line, got %v", lines)
}
if !foundSuccess {
t.Errorf("expected 'Success' log line, got %v", lines)
}
}
func TestDownloadMods_StubSteamcmdArgsLogged(t *testing.T) {
dir := t.TempDir()
streamer := NewLogStreamer()
sm := NewSteamCmdManager(dir, streamer)
stubPath, _ := filepath.Abs("testdata/steamcmd")
t.Setenv("STEAMCMD_PATH", stubPath)
ch := streamer.Subscribe("steamcmd", "test-args-logs")
defer streamer.Unsubscribe("steamcmd", "test-args-logs")
err := sm.DownloadMod("555555")
if err != nil {
t.Fatalf("DownloadMod error = %v", err)
}
lines := readSteamcmdLines(ch, 20, 5*time.Second)
foundArgsLog := false
for _, line := range lines {
if strings.Contains(line, "[STUB]") && strings.Contains(line, "install_dir=") {
foundArgsLog = true
break
}
}
if !foundArgsLog {
t.Errorf("expected [STUB] install_dir= log line, got %v", lines)
}
}
// readSteamcmdLines reads lines from a steamcmd log channel
func readSteamcmdLines(ch chan string, n int, timeout time.Duration) []string {
var lines []string
deadline := time.Now().Add(timeout)
for len(lines) < n {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
select {
case line, ok := <-ch:
if !ok {
return lines
}
lines = append(lines, line)
case <-time.After(remaining):
return lines
}
}
return lines
}
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
# Stub arma3server binary for testing.
# Logs binary path and all arguments, prints heartbeat every 5s.
# Writes RPT log entries to the profiles directory (like the real server).
# Use -t N to exit after N seconds (for auto-exit tests).
echo "[STUB] binary=$0 args=$*"
DURATION=0
PROFILES_DIR=""
while [ $# -gt 0 ]; do
case "$1" in
-t) DURATION="$2"; shift 2 ;;
-profiles=*) PROFILES_DIR="${1#-profiles=}"; shift ;;
*) shift ;;
esac
done
# Create RPT file in profiles dir if provided
RPT_FILE=""
if [ -n "$PROFILES_DIR" ]; then
mkdir -p "$PROFILES_DIR"
RPT_FILE="$PROFILES_DIR/arma3server_x64_$(date +%Y%m%d_%H%M%S).rpt"
echo "--- RPT log started ---" > "$RPT_FILE"
echo "Exe timestamp: 2024/01/15 12:00:00" >> "$RPT_FILE"
echo "Computer name: STUB-TEST" >> "$RPT_FILE"
echo "Operating system: Linux" >> "$RPT_FILE"
fi
write_rpt() {
if [ -n "$RPT_FILE" ]; then
echo "$1" >> "$RPT_FILE"
fi
}
TICK=0
if [ "$DURATION" -gt 0 ] 2>/dev/null; then
ELAPSED=0
while [ "$ELAPSED" -lt "$DURATION" ]; do
REMAINING=$((DURATION - ELAPSED))
WAIT=5
if [ "$REMAINING" -lt 5 ]; then WAIT=$REMAINING; fi
sleep "$WAIT"
ELAPSED=$((ELAPSED + WAIT))
TICK=$((TICK + 1))
echo "[STUB] heartbeat elapsed=${ELAPSED}s remaining=$((REMAINING - WAIT))s"
write_rpt "$(date +%H:%M:%S) MissionEditor: Mission file selected: test_mission.Tanoa"
write_rpt "$(date +%H:%M:%S) World: Avoid neighbor island: 0.00 ms (1.00 ms)"
write_rpt "$(date +%H:%M:%S) Server: Player #1 connected (id=12345)"
done
write_rpt "$(date +%H:%M:%S) Server: Shutdown completed"
echo "[STUB] server exiting after ${DURATION}s"
else
while true; do
sleep 5
TICK=$((TICK + 1))
echo "[STUB] heartbeat running..."
write_rpt "$(date +%H:%M:%S) MissionEditor: Mission file selected: test_mission.Tanoa"
write_rpt "$(date +%H:%M:%S) World: Avoid neighbor island: 0.00 ms (1.00 ms)"
write_rpt "$(date +%H:%M:%S) Server: Player #1 connected (id=12345)"
done
fi
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Stub steamcmd binary for testing.
# Parses +force_install_dir and +workshop_download_item args.
# Creates fake mod directories and prints fake download logs.
INSTALL_DIR=""
MOD_IDS=""
while [ $# -gt 0 ]; do
case "$1" in
+force_install_dir) INSTALL_DIR="$2"; shift 2 ;;
+workshop_download_item)
APP_ID="$2"; MOD_ID="$3"; shift 3
MOD_IDS="$MOD_IDS $MOD_ID"
;;
*) shift ;;
esac
done
echo "[STUB] steamcmd install_dir=$INSTALL_DIR mods=$MOD_IDS"
for MOD_ID in $MOD_IDS; do
MOD_DIR="$INSTALL_DIR/steamapps/workshop/content/107410/$MOD_ID"
echo "[STUB] Downloading item $MOD_ID (App $APP_ID)..."
echo "[STUB] Downloading 100% [//////////]"
mkdir -p "$MOD_DIR"
echo "[STUB] Success! App $APP_ID item $MOD_ID installed to $MOD_DIR"
done
echo "[STUB] steamcmd exiting"
exit 0
-1
View File
@@ -14,6 +14,5 @@ services:
- CFG_DIR=/servers/cfg
- PROFILES_DIR=/servers/profiles
- LISTEN=:8080
- FRONTEND_DIR=/usr/share/arma3-web-server/frontend
- GIN_MODE=release
restart: unless-stopped
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>arma3-frontend</title>
<title>Arma 3 Server Manager</title>
</head>
<body>
<div id="root"></div>
+1180 -18
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -7,6 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview"
},
"dependencies": {
@@ -18,16 +20,19 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"tailwindcss": "^4.3.2",
"zustand": "^5.0.14"
"tailwindcss": "^4.3.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
"vite": "^8.1.1",
"vitest": "^4.1.10"
}
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import Layout from './Layout'
describe('Layout', () => {
it('renders sidebar navigation', () => {
render(
<MemoryRouter>
<Layout>
<div>Content</div>
</Layout>
</MemoryRouter>
)
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByText('Configs')).toBeInTheDocument()
expect(screen.getByText('Modlists')).toBeInTheDocument()
expect(screen.getByText('Mods')).toBeInTheDocument()
expect(screen.getByText('Logs')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
})
it('renders children', () => {
render(
<MemoryRouter>
<Layout>
<div>Test Content</div>
</Layout>
</MemoryRouter>
)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('highlights active route', () => {
render(
<MemoryRouter initialEntries={['/settings']}>
<Layout>
<div>Content</div>
</Layout>
</MemoryRouter>
)
const settingsLink = screen.getByText('Settings')
expect(settingsLink).toHaveClass('text-white')
})
})
+9 -19
View File
@@ -2,12 +2,16 @@ import { useParams, Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { configsApi } from '../api/client'
import { useState, useEffect } from 'react'
import Editor from '@monaco-editor/react'
import ConfigEditorComponent from '../components/ConfigEditor'
export default function ConfigEditor() {
const { name } = useParams<{ name: string }>()
const qc = useQueryClient()
const { data: content, isLoading } = useQuery({ queryKey: ['config', name], queryFn: () => configsApi.get(name!) })
const { data: content, isLoading } = useQuery({
queryKey: ['config', name],
queryFn: () => configsApi.get(name!),
enabled: !!name,
})
const [value, setValue] = useState('')
const [dirty, setDirty] = useState(false)
@@ -18,8 +22,8 @@ export default function ConfigEditor() {
onSuccess: () => { qc.invalidateQueries({ queryKey: ['config', name] }); setDirty(false) },
})
if (isLoading) return <p className="text-neutral-500">Loading...</p>
if (!name) return <p className="text-red-400">No config specified</p>
if (isLoading) return <p className="text-neutral-500">Loading...</p>
return (
<div>
@@ -33,24 +37,10 @@ export default function ConfigEditor() {
</button>
</div>
<div className="border border-neutral-800 rounded-lg overflow-hidden">
<Editor
height="600px"
language="plaintext"
<ConfigEditorComponent
value={value}
onChange={v => { setValue(v ?? ''); setDirty(true) }}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
}}
onChange={v => { setValue(v); setDirty(true) }}
/>
</div>
</div>
)
}
+6 -7
View File
@@ -3,17 +3,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { modlistsApi, steamcmdApi, logsApi } from '../api/client'
import { useState, useEffect } from 'react'
import LiveTerminal from '../components/LiveTerminal'
interface ModEntry {
id: string
name: string
enabled: boolean
}
import type { ModEntry } from '../types'
export default function ModlistEditor() {
const { id } = useParams<{ id: string }>()
const qc = useQueryClient()
const { data: modlist, isLoading } = useQuery({ queryKey: ['modlist', id], queryFn: () => modlistsApi.get(id!) })
const { data: modlist, isLoading } = useQuery({
queryKey: ['modlist', id],
queryFn: () => modlistsApi.get(id!),
enabled: !!id,
})
const [name, setName] = useState('')
const [mods, setMods] = useState<ModEntry[]>([])
const [dirty, setDirty] = useState(false)
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import Mods from './Mods'
const mockList = vi.fn()
vi.mock('../api/client', () => ({
modsApi: {
list: (...args: unknown[]) => mockList(...args),
remove: vi.fn(),
cleanup: vi.fn(),
},
}))
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
describe('Mods', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders loading state then data', async () => {
mockList.mockResolvedValue([])
const queryClient = createTestQueryClient()
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Mods />
</MemoryRouter>
</QueryClientProvider>
)
expect(screen.getByText('Loading...')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Installed Mods')).toBeInTheDocument()
})
})
it('renders search input after load', async () => {
mockList.mockResolvedValue([])
const queryClient = createTestQueryClient()
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Mods />
</MemoryRouter>
</QueryClientProvider>
)
await waitFor(() => {
expect(screen.getByPlaceholderText('Filter by name or ID...')).toBeInTheDocument()
})
})
it('shows no mods message when empty', async () => {
mockList.mockResolvedValue([])
const queryClient = createTestQueryClient()
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Mods />
</MemoryRouter>
</QueryClientProvider>
)
await waitFor(() => {
expect(screen.getByText('No mods found.')).toBeInTheDocument()
})
})
})
+1 -8
View File
@@ -1,14 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { modsApi } from '../api/client'
import { useState } from 'react'
function fmtSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const v = bytes / Math.pow(1024, i)
return `${v.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
import { fmtSize } from '../utils/format'
export default function Mods() {
const qc = useQueryClient()
+1 -7
View File
@@ -1,13 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import { settingsApi } from '../api/client'
function fmtSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]
}
import { fmtSize } from '../utils/format'
function fmtPercent(p: number): string {
return p.toFixed(1) + '%'
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom'
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { fmtSize } from './format'
describe('fmtSize', () => {
it('formats 0 bytes', () => {
expect(fmtSize(0)).toBe('0 B')
})
it('formats bytes', () => {
expect(fmtSize(500)).toBe('500 B')
})
it('formats kilobytes', () => {
expect(fmtSize(1024)).toBe('1.0 KB')
expect(fmtSize(1536)).toBe('1.5 KB')
})
it('formats megabytes', () => {
expect(fmtSize(1048576)).toBe('1.0 MB')
expect(fmtSize(5242880)).toBe('5.0 MB')
})
it('formats gigabytes', () => {
expect(fmtSize(1073741824)).toBe('1.0 GB')
})
it('formats terabytes', () => {
expect(fmtSize(1099511627776)).toBe('1.0 TB')
})
it('handles edge case at 1024 boundary', () => {
expect(fmtSize(1023)).toBe('1023 B')
expect(fmtSize(1025)).toBe('1.0 KB')
})
})
+7
View File
@@ -0,0 +1,7 @@
export function fmtSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const v = bytes / Math.pow(1024, i)
return `${v.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
},
})
+616
View File
@@ -0,0 +1,616 @@
<?xml version="1.0" encoding="utf-8"?>
<html>
<!--Created by Arma 3 Launcher: https://arma3.com-->
<head>
<meta name="arma:Type" content="list" />
<meta name="generator" content="Arma 3 Launcher - https://arma3.com" />
<title>Arma 3</title>
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css" />
<style>
body {
margin: 0;
padding: 0;
color: #fff;
background: #000;
}
body, th, td {
font: 95%/1.3 Roboto, Segoe UI, Tahoma, Arial, Helvetica, sans-serif;
}
td {
padding: 3px 30px 3px 0;
}
h1 {
padding: 20px 20px 0 20px;
color: white;
font-weight: 200;
font-family: segoe ui;
font-size: 3em;
margin: 0;
}
em {
font-variant: italic;
color:silver;
}
.before-list {
padding: 5px 20px 10px 20px;
}
.mod-list {
background: #222222;
padding: 20px;
}
.dlc-list {
background: #222222;
padding: 20px;
}
.footer {
padding: 20px;
color:gray;
}
.whups {
color:gray;
}
a {
color: #D18F21;
text-decoration: underline;
}
a:hover {
color:#F1AF41;
text-decoration: none;
}
.from-steam {
color: #449EBD;
}
.from-local {
color: gray;
}
</style>
</head>
<body>
<h1>Arma 3 Mods</h1>
<p class="before-list">
<em>To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file.</em>
</p>
<div class="mod-list">
<table>
<tr data-type="ModContainer">
<td data-type="DisplayName">[REMASTERED!!!] BBM Varta | Варта ББМ</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3498496846" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3498496846</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">2B9 Vasilek</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3183189354" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3183189354</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">3CB Fortifications</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2970917337" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2970917337</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">ace</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=463939057" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=463939057</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">ACE3 Arsenal Extended - Core</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2522638637" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2522638637</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Adjustable Walking Speed</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2513253040" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2513253040</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Arma Realistic Map Assets V2</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2982306133" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2982306133</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Aselsan Military Communication Systems</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2407683902" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2407683902</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Better Inventory</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2791403093" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2791403093</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">CBA_A3</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=450814997</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">CUP Terrains - Core</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=583496184" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=583496184</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">D-20 M1955</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3356373534" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3356373534</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">DCO Soldier FSM</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2825929474" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2825929474</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">DCO SoldierFSM Evolution</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3508523598" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3508523598</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">DCO UnitScanner</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2811378998" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2811378998</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">DCO Vehicle.FSM - Vehicle AI Enhancement (No longer updated)</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2760263165" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2760263165</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">DUI - Squad Radar</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Enhanced Map Ace Version</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2467590475" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2467590475</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Enhanced Movement</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=333310405" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=333310405</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Enhanced Movement Rework</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2034363662" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2034363662</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Females-TCGM_Girls</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2261045061" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2261045061</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">FPV Drone Crocus</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3045129955" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3045129955</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Gruppe Adler Trenches</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1224892496" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1224892496</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">HAG Objects (SIGNED)</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3164954322" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3164954322</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Heavy Weapons Framework</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3328314886" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3328314886</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Hendrix Russian and Ukraine Gear</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3414879940" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3414879940</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Immerse</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=825172265" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=825172265</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">JCA - Infantry Arsenal</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3333302397" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3333302397</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Kamikaze Drone (FPV drones)</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2957974874" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2957974874</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">KAT - Advanced Medical</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2020940806" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2020940806</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Ladder Tweak Remastered</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2969350304" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2969350304</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">LR Armed Forces of Ukraine</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3266957416" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3266957416</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">LR Modern Armed Forces of Russian Federation Full</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3352497264" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3352497264</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">LR Modern Armed Forces of Russian Federation Lite</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3420356276" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3420356276</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">MT-12 Rapira</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3251627825" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3251627825</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">MT-LB - The Soviet Workhorse</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3615518979" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3615518979</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">NIArms All in One</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1208517358" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1208517358</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">NMG mod</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2145354279" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2145354279</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">POOK Camonets</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=943981276" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=943981276</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Prone Launcher</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1841047025" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1841047025</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Real Engine Enhanced</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3715450352" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3715450352</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Realistic Trench Digging</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3571588952" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3571588952</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">RHSAFRF</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843425103" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843425103</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">RHSGREF</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843593391" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843593391</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">RHSSAF</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843632231" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843632231</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">RHSTERRACORE</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2288691268" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2288691268</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">RHSUSAF</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843577117" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843577117</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Ruha</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1368857262" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1368857262</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">SCAR Weapon Family (JCA Expansion)</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3345739516" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3345739516</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Simple Armbands</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2778578325" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2778578325</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">SPS Weapons V2</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2811886291" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2811886291</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Switchblade Loitering Munition</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2797736391" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2797736391</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Task Force Arrowhead Radio (BETA!!!)</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=894678801" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=894678801</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">tbd_mortars</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3058335345" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3058335345</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">UAF Vehicles Pack</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2965680724" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2965680724</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Ukrainian Armed Forces Gear - Monk</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3030483911" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3030483911</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Ukrainian Factions Project</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2673284246" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2673284246</a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Ukrainian Military Technics 2022-2024</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3158623183" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3158623183</a>
</td>
</tr>
</table>
</div>
<div class="footer">
<span>Created by Arma 3 Launcher by Bohemia Interactive.</span>
</div>
</body>
</html>