Compare commits
18
Commits
914e0fbe48
...
6f3175ac9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f3175ac9a | ||
|
|
41c1390972 | ||
|
|
930b6cbca6 | ||
|
|
04882b3c80 | ||
|
|
1307bc4740 | ||
|
|
0085ca78fd | ||
|
|
5134a3b261 | ||
|
|
234116e6a1 | ||
|
|
03c3d7bae2 | ||
|
|
bc23ad7daa | ||
|
|
311cc01ce5 | ||
|
|
0e58b36bd2 | ||
|
|
d66f8bdd26 | ||
|
|
d54c46d2ad | ||
|
|
f4d98830dc | ||
|
|
6e628a0bba | ||
|
|
9f232b5225 | ||
|
|
d55200886b |
@@ -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
|
||||
|
||||
@@ -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,10 +2,12 @@ node_modules/
|
||||
dist/
|
||||
frontend/dist/
|
||||
backend/embed/dist/
|
||||
bin/
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
backend/arma3-web-server
|
||||
backend/server
|
||||
|
||||
data/
|
||||
serverfiles/
|
||||
|
||||
+58
-19
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+1180
-18
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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) + '%'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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}'],
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user