This commit is contained in:
@@ -12,12 +12,17 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.24"
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
- run: cd frontend && npm ci && npm run build
|
- run: cd frontend && npm ci && npm run build
|
||||||
- run: mkdir -p backend/embed/dist && cp -r frontend/dist/. backend/embed/dist/
|
- run: mkdir -p backend/embed/dist && cp -r frontend/dist/. backend/embed/dist/
|
||||||
- run: cd backend && go build ./...
|
- run: cd backend && go build ./...
|
||||||
- run: cd backend && go test ./...
|
- run: cd backend && go test -race ./...
|
||||||
|
- run: cd frontend && npm run lint
|
||||||
- run: cd frontend && npm test
|
- run: cd frontend && npm test
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.24"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|||||||
+8
-7
@@ -1,10 +1,3 @@
|
|||||||
FROM golang:1.25-bookworm AS backend
|
|
||||||
WORKDIR /src
|
|
||||||
COPY backend/go.mod backend/go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY backend/ .
|
|
||||||
RUN CGO_ENABLED=0 go build -o /server ./cmd/server/
|
|
||||||
|
|
||||||
FROM node:22-alpine AS frontend
|
FROM node:22-alpine AS frontend
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
@@ -12,6 +5,14 @@ RUN npm ci
|
|||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.24-bookworm AS backend
|
||||||
|
WORKDIR /src
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY --from=frontend /src/dist /src/embed/dist
|
||||||
|
COPY backend/ .
|
||||||
|
RUN CGO_ENABLED=0 go build -o /server ./cmd/server/
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ clean:
|
|||||||
rm -rf frontend/dist backend/embed/dist bin
|
rm -rf frontend/dist backend/embed/dist bin
|
||||||
|
|
||||||
test-backend:
|
test-backend:
|
||||||
cd backend && go test ./...
|
cd backend && go test -race ./...
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
cd frontend && npm test
|
cd frontend && npm test
|
||||||
|
|||||||
@@ -24,18 +24,22 @@ The web UI is served on `http://localhost:8080`.
|
|||||||
|
|
||||||
### Manual
|
### Manual
|
||||||
|
|
||||||
```bash
|
**Prerequisites:**
|
||||||
# Backend
|
- [Go](https://go.dev/dl/) >= 1.24
|
||||||
cd backend
|
- [Node.js](https://nodejs.org/) >= 22 with npm
|
||||||
go build -o arma3-web-server ./cmd/server
|
- GNU Make (optional, for `make` targets)
|
||||||
./arma3-web-server
|
|
||||||
|
|
||||||
# Frontend (development)
|
```bash
|
||||||
cd frontend
|
# Build for production (frontend + backend in one binary)
|
||||||
npm install
|
make build
|
||||||
npm run dev
|
./backend/arma3-web-server
|
||||||
|
|
||||||
|
# Or develop with hot reload (uses test stubs, no real SteamCMD needed)
|
||||||
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The web UI is served on `http://localhost:8080`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All paths are configurable via environment variables:
|
All paths are configurable via environment variables:
|
||||||
@@ -51,6 +55,9 @@ All paths are configurable via environment variables:
|
|||||||
| `SERVER_BINARY` | `arma3server_x64` | Server binary filename (overrides platform default) |
|
| `SERVER_BINARY` | `arma3server_x64` | Server binary filename (overrides platform default) |
|
||||||
| `SERVER_PARAMS` | `-server -world=empty ...` | Override server launch parameters |
|
| `SERVER_PARAMS` | `-server -world=empty ...` | Override server launch parameters |
|
||||||
| `STEAMCMD_PATH` | `steamcmd` | Path to steamcmd binary |
|
| `STEAMCMD_PATH` | `steamcmd` | Path to steamcmd binary |
|
||||||
|
| `AUTH_TOKEN` | _(empty)_ | Bearer token for API/WS auth. When set, all requests must include `Authorization: Bearer <token>`. When empty, no auth required. |
|
||||||
|
| `GIN_MODE` | `debug` | Gin framework mode. Use `release` for production (set automatically in Docker). |
|
||||||
|
| `SERVERS_DIR` | `./serverfiles` | Docker Compose only: host directory mounted as `/servers` in the container. |
|
||||||
|
|
||||||
## Automation
|
## Automation
|
||||||
|
|
||||||
@@ -66,7 +73,7 @@ Configured in the **Automation** section of the Settings UI:
|
|||||||
| **Auto-update mods on startup** | Downloads workshop updates for every enabled mod in the active modlist when the web service starts. |
|
| **Auto-update mods on startup** | Downloads workshop updates for every enabled mod in the active modlist when the web service starts. |
|
||||||
| **Auto-start server on startup** | Restarts the game server if it was running when the web service last stopped. Useful for recovery after host backup cycles or container restarts. |
|
| **Auto-start server on startup** | Restarts the game server if it was running when the web service last stopped. Useful for recovery after host backup cycles or container restarts. |
|
||||||
|
|
||||||
All startup tasks run asynchronously — the web UI is available immediately.
|
All startup tasks run asynchronously — the web UI is available immediately. Server and mod updates run in parallel; auto-start waits for both to complete before launching the game server.
|
||||||
|
|
||||||
### Scheduled updates
|
### Scheduled updates
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -91,8 +92,12 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("startup: load settings: %v", err)
|
log.Printf("startup: load settings: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
var updatesWg sync.WaitGroup
|
||||||
|
|
||||||
if s.AutoUpdateOnStartup && s.SteamUser != "" {
|
if s.AutoUpdateOnStartup && s.SteamUser != "" {
|
||||||
|
updatesWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer updatesWg.Done()
|
||||||
log.Print("startup: auto-updating gameserver")
|
log.Print("startup: auto-updating gameserver")
|
||||||
if err := steamcmd.UpdateGame(s.SteamBranch, s.SteamUser); err != nil {
|
if err := steamcmd.UpdateGame(s.SteamBranch, s.SteamUser); err != nil {
|
||||||
log.Printf("startup: auto-update game failed: %v", err)
|
log.Printf("startup: auto-update game failed: %v", err)
|
||||||
@@ -101,7 +106,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.AutoUpdateModsOnStartup && s.ActiveModlist != "" {
|
if s.AutoUpdateModsOnStartup && s.ActiveModlist != "" {
|
||||||
|
updatesWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer updatesWg.Done()
|
||||||
log.Print("startup: auto-updating mods")
|
log.Print("startup: auto-updating mods")
|
||||||
ml, err := modlistMgr.Get(s.ActiveModlist)
|
ml, err := modlistMgr.Get(s.ActiveModlist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,6 +131,7 @@ func main() {
|
|||||||
|
|
||||||
if s.AutoStartOnStartup && s.WasRunning {
|
if s.AutoStartOnStartup && s.WasRunning {
|
||||||
go func() {
|
go func() {
|
||||||
|
updatesWg.Wait()
|
||||||
log.Print("startup: auto-restarting server (was running before)")
|
log.Print("startup: auto-restarting server (was running before)")
|
||||||
if err := process.Start(); err != nil {
|
if err := process.Start(); err != nil {
|
||||||
log.Printf("startup: auto-start failed: %v", err)
|
log.Printf("startup: auto-start failed: %v", err)
|
||||||
@@ -152,6 +160,13 @@ func main() {
|
|||||||
log.Print("shutting down server...")
|
log.Print("shutting down server...")
|
||||||
scheduler.Stop()
|
scheduler.Stop()
|
||||||
|
|
||||||
|
if process.IsRunning() {
|
||||||
|
log.Print("stopping game server...")
|
||||||
|
if err := process.Stop(); err != nil {
|
||||||
|
log.Printf("stop game server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
|||||||
@@ -195,7 +195,13 @@ func (h *Handler) StreamRPTLogs(c *gin.Context) {
|
|||||||
if _, err := f.Seek(currentOffset, io.SeekStart); err != nil {
|
if _, err := f.Seek(currentOffset, io.SeekStart); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
buf := make([]byte, fi.Size()-currentOffset)
|
remaining := fi.Size() - currentOffset
|
||||||
|
const maxBuf = 64 * 1024
|
||||||
|
bufSize := remaining
|
||||||
|
if bufSize > maxBuf {
|
||||||
|
bufSize = maxBuf
|
||||||
|
}
|
||||||
|
buf := make([]byte, bufSize)
|
||||||
n, _ := io.ReadFull(f, buf)
|
n, _ := io.ReadFull(f, buf)
|
||||||
|
|
||||||
currentOffset += int64(n)
|
currentOffset += int64(n)
|
||||||
@@ -260,6 +266,16 @@ func (h *Handler) GetLog(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const maxLogSize = 10 * 1024 * 1024
|
||||||
|
if fi.Size() > maxLogSize {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "log file too large, use live streaming"})
|
||||||
|
return
|
||||||
|
}
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ func (h *Handler) ListMods(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, all)
|
c.JSON(http.StatusOK, all)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isUnderDir(path, dir string) bool {
|
||||||
|
rel, err := filepath.Rel(dir, path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) DeleteMod(c *gin.Context) {
|
func (h *Handler) DeleteMod(c *gin.Context) {
|
||||||
var input deleteModInput
|
var input deleteModInput
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
@@ -62,7 +70,7 @@ func (h *Handler) DeleteMod(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(absPath, h.serverfileDir) && !strings.HasPrefix(absPath, h.modsDir) {
|
if !isUnderDir(absPath, h.serverfileDir) && !isUnderDir(absPath, h.modsDir) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "path outside allowed directories"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "path outside allowed directories"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"arma3-web-server/internal/services"
|
"arma3-web-server/internal/services"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -20,6 +24,7 @@ type Handler struct {
|
|||||||
cfgDir string
|
cfgDir string
|
||||||
profilesDir string
|
profilesDir string
|
||||||
frontendServed bool
|
frontendServed bool
|
||||||
|
authToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
@@ -47,11 +52,44 @@ func New(
|
|||||||
cfgDir: cfgDir,
|
cfgDir: cfgDir,
|
||||||
profilesDir: profilesDir,
|
profilesDir: profilesDir,
|
||||||
frontendServed: frontendServed,
|
frontendServed: frontendServed,
|
||||||
|
authToken: os.Getenv("AUTH_TOKEN"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) authMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.authToken == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid authorization header"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
if !secureCompare(token, h.authToken) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secureCompare(a, b string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(a); i++ {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) SetupRoutes(r *gin.Engine) {
|
func (h *Handler) SetupRoutes(r *gin.Engine) {
|
||||||
api := r.Group("/api")
|
api := r.Group("/api", h.authMiddleware())
|
||||||
{
|
{
|
||||||
api.GET("/server/settings", h.GetSettings)
|
api.GET("/server/settings", h.GetSettings)
|
||||||
api.PUT("/server/settings", h.UpdateSettings)
|
api.PUT("/server/settings", h.UpdateSettings)
|
||||||
@@ -93,7 +131,8 @@ func (h *Handler) SetupRoutes(r *gin.Engine) {
|
|||||||
api.POST("/mods/cleanup", h.CleanupMods)
|
api.POST("/mods/cleanup", h.CleanupMods)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.GET("/ws/server/logs", h.StreamLogs)
|
ws := r.Group("/ws", h.authMiddleware())
|
||||||
r.GET("/ws/steamcmd/logs", h.StreamSteamCMDLogs)
|
ws.GET("/server/logs", h.StreamLogs)
|
||||||
r.GET("/ws/server/rpt", h.StreamRPTLogs)
|
ws.GET("/steamcmd/logs", h.StreamSteamCMDLogs)
|
||||||
|
ws.GET("/server/rpt", h.StreamRPTLogs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"arma3-web-server/internal/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type updateSettingsInput struct {
|
type updateSettingsInput struct {
|
||||||
@@ -39,12 +45,33 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := h.settings.Load()
|
if input.IPPort != nil && *input.IPPort != "" {
|
||||||
if err != nil {
|
if _, _, err := net.SplitHostPort(*input.IPPort); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid ip_port: must be host:port format: %v", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if input.ServerParameters != nil && len(*input.ServerParameters) > 8192 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "server_parameters too long (max 8192)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.ScheduledUpdate != nil && *input.ScheduledUpdate != "" {
|
||||||
|
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||||
|
if _, err := parser.Parse(*input.ScheduledUpdate); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid cron expression: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.ServerParameters != nil {
|
||||||
|
for _, forbidden := range []string{"--dry-run", "--rm", "--privileged"} {
|
||||||
|
if strings.Contains(*input.ServerParameters, forbidden) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("server_parameters contains forbidden flag: %s", forbidden)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := h.settings.Update(func(s *models.ServerSettings) {
|
||||||
if input.IPPort != nil {
|
if input.IPPort != nil {
|
||||||
s.IPPort = *input.IPPort
|
s.IPPort = *input.IPPort
|
||||||
}
|
}
|
||||||
@@ -87,14 +114,14 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
|
|||||||
if input.ScheduledUpdate != nil {
|
if input.ScheduledUpdate != nil {
|
||||||
s.ScheduledUpdate = *input.ScheduledUpdate
|
s.ScheduledUpdate = *input.ScheduledUpdate
|
||||||
}
|
}
|
||||||
|
})
|
||||||
if err := h.process.WriteUserconfigFiles(s); err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.settings.Save(s); err != nil {
|
if err := h.process.WriteUserconfigFiles(s); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ func (cm *ConfigManager) Create(name, content string) error {
|
|||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return fmt.Errorf("config already exists")
|
return fmt.Errorf("config already exists")
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, []byte(content), 0644)
|
return writeFileAtomic(path, []byte(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ConfigManager) Update(name, content string) error {
|
func (cm *ConfigManager) Update(name, content string) error {
|
||||||
return os.WriteFile(cm.path(name), []byte(content), 0644)
|
return writeFileAtomic(cm.path(name), []byte(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ConfigManager) Delete(name string) error {
|
func (cm *ConfigManager) Delete(name string) error {
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"arma3-web-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListWorkshopMods_Empty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mods := ListWorkshopMods(dir)
|
||||||
|
if len(mods) != 0 {
|
||||||
|
t.Fatalf("expected 0 mods, got %d", len(mods))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListWorkshopMods_WithMods(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410")
|
||||||
|
if err := os.MkdirAll(filepath.Join(workshopDir, "123456"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(workshopDir, "789012"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := ListWorkshopMods(dir)
|
||||||
|
if len(mods) != 2 {
|
||||||
|
t.Fatalf("expected 2 mods, got %d", len(mods))
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, m := range mods {
|
||||||
|
ids[m.ID] = true
|
||||||
|
if m.Source != "workshop" {
|
||||||
|
t.Errorf("expected source 'workshop', got %q", m.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ids["123456"] || !ids["789012"] {
|
||||||
|
t.Errorf("expected both mod IDs, got %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListWorkshopMods_SkipsFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410")
|
||||||
|
if err := os.MkdirAll(workshopDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(workshopDir, "notadir.txt"), []byte("hi"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(workshopDir, "111111"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := ListWorkshopMods(dir)
|
||||||
|
if len(mods) != 1 {
|
||||||
|
t.Fatalf("expected 1 mod, got %d", len(mods))
|
||||||
|
}
|
||||||
|
if mods[0].ID != "111111" {
|
||||||
|
t.Errorf("expected ID '111111', got %q", mods[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLocalMods_Empty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mods := ListLocalMods(dir)
|
||||||
|
if len(mods) != 0 {
|
||||||
|
t.Fatalf("expected 0 mods, got %d", len(mods))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLocalMods_WithMods(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "@ACE3"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "@TFAR"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := ListLocalMods(dir)
|
||||||
|
if len(mods) != 2 {
|
||||||
|
t.Fatalf("expected 2 mods, got %d", len(mods))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range mods {
|
||||||
|
if m.Source != "local" {
|
||||||
|
t.Errorf("expected source 'local', got %q", m.Source)
|
||||||
|
}
|
||||||
|
if len(m.Name) > 0 && m.Name[0] != '@' {
|
||||||
|
t.Errorf("expected name to start with '@', got %q", m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLocalMods_SkipsFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("hi"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "@Mod"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := ListLocalMods(dir)
|
||||||
|
if len(mods) != 1 {
|
||||||
|
t.Fatalf("expected 1 mod, got %d", len(mods))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUsageMap(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
mm := NewModlistManager(dataDir)
|
||||||
|
|
||||||
|
ml, err := mm.Create("Test List")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ml, err = mm.Update(ml.ID, ml.Name, []models.ModEntry{
|
||||||
|
{ID: "111111", Name: "ACE3", Enabled: true},
|
||||||
|
{ID: "222222", Name: "", Enabled: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = ml
|
||||||
|
|
||||||
|
usedWorkshop, usedLocal := BuildUsageMap(mm)
|
||||||
|
|
||||||
|
if lists, ok := usedWorkshop["111111"]; !ok || len(lists) == 0 {
|
||||||
|
t.Errorf("expected workshop mod 111111 to be in use")
|
||||||
|
}
|
||||||
|
if _, ok := usedWorkshop["222222"]; !ok {
|
||||||
|
t.Errorf("expected workshop mod 222222 to be in use")
|
||||||
|
}
|
||||||
|
if _, ok := usedLocal["ACE3"]; !ok {
|
||||||
|
t.Errorf("expected local mod ACE3 to be in use")
|
||||||
|
}
|
||||||
|
if _, ok := usedLocal["@ACE3"]; !ok {
|
||||||
|
t.Errorf("expected local mod @ACE3 to be in use")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUsageMap_Empty(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
mm := NewModlistManager(dataDir)
|
||||||
|
|
||||||
|
usedWorkshop, usedLocal := BuildUsageMap(mm)
|
||||||
|
if len(usedWorkshop) != 0 || len(usedLocal) != 0 {
|
||||||
|
t.Errorf("expected empty maps, got workshop=%v local=%v", usedWorkshop, usedLocal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveMod(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
modDir := filepath.Join(dir, "@TestMod")
|
||||||
|
if err := os.MkdirAll(modDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(modDir, "config.cpp"), []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RemoveMod(modDir); err != nil {
|
||||||
|
t.Fatalf("RemoveMod failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(modDir); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected directory to be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveMod_Nonexistent(t *testing.T) {
|
||||||
|
err := RemoveMod("/nonexistent/path/that/does/not/exist")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("RemoveMod on nonexistent path should not error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirSize(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sub := filepath.Join(dir, "sub")
|
||||||
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("world!"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := dirSize(dir)
|
||||||
|
if size != 11 {
|
||||||
|
t.Errorf("expected size 11, got %d", size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirSize_Empty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
size := dirSize(dir)
|
||||||
|
if size != 0 {
|
||||||
|
t.Errorf("expected size 0, got %d", size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ func (mm *ModlistManager) Create(name string) (*models.Modlist, error) {
|
|||||||
if err := os.MkdirAll(mm.dir, 0755); err != nil {
|
if err := os.MkdirAll(mm.dir, 0755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(mm.path(m.ID), data, 0644); err != nil {
|
if err := writeFileAtomic(mm.path(m.ID), data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -126,7 +126,7 @@ func (mm *ModlistManager) Update(id, name string, mods []models.ModEntry) (*mode
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(mm.path(id), data, 0644); err != nil {
|
if err := writeFileAtomic(mm.path(id), data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -160,7 +160,7 @@ func (mm *ModlistManager) Duplicate(id, newName string) (*models.Modlist, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(mm.path(dup.ID), data, 0644); err != nil {
|
if err := writeFileAtomic(mm.path(dup.ID), data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return dup, nil
|
return dup, nil
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"arma3-web-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewScheduler(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
s := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected non-nil scheduler")
|
||||||
|
}
|
||||||
|
if s.cron == nil {
|
||||||
|
t.Fatal("expected non-nil cron")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_StartStop(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
s := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
s.Start()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
s.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_Refresh_Empty(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
s := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
s.Start()
|
||||||
|
defer s.Stop()
|
||||||
|
|
||||||
|
s.Refresh()
|
||||||
|
if s.entryID != 0 {
|
||||||
|
t.Errorf("expected entryID 0 with empty settings, got %d", s.entryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_Refresh_InvalidCron(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
_, err := settings.Update(func(s *models.ServerSettings) {
|
||||||
|
s.ScheduledUpdate = "not-a-cron"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sched := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
sched.Start()
|
||||||
|
defer sched.Stop()
|
||||||
|
|
||||||
|
sched.Refresh()
|
||||||
|
if sched.entryID != 0 {
|
||||||
|
t.Errorf("expected entryID 0 with invalid cron, got %d", sched.entryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_Refresh_ValidCron(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
_, err := settings.Update(func(s *models.ServerSettings) {
|
||||||
|
s.ScheduledUpdate = "0 4 * * *"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sched := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
sched.Start()
|
||||||
|
defer sched.Stop()
|
||||||
|
|
||||||
|
sched.Refresh()
|
||||||
|
if sched.entryID == 0 {
|
||||||
|
t.Errorf("expected non-zero entryID with valid cron")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_Refresh_Replace(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
sched := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
sched.Start()
|
||||||
|
defer sched.Stop()
|
||||||
|
|
||||||
|
_, err := settings.Update(func(s *models.ServerSettings) {
|
||||||
|
s.ScheduledUpdate = "0 4 * * *"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sched.Refresh()
|
||||||
|
id1 := sched.entryID
|
||||||
|
|
||||||
|
_, err = settings.Update(func(s *models.ServerSettings) {
|
||||||
|
s.ScheduledUpdate = "0 5 * * *"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sched.Refresh()
|
||||||
|
id2 := sched.entryID
|
||||||
|
|
||||||
|
if id1 == id2 {
|
||||||
|
t.Errorf("expected different entryIDs after refresh, both got %d", id1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -184,7 +185,9 @@ func (pm *ProcessManager) Stop() error {
|
|||||||
s, err := pm.settings.Load()
|
s, err := pm.settings.Load()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.WasRunning = false
|
s.WasRunning = false
|
||||||
pm.settings.Save(s)
|
if err := pm.settings.Save(s); err != nil {
|
||||||
|
log.Printf("stop: save was_running: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -307,7 +310,7 @@ func (pm *ProcessManager) WriteUserconfigFiles(s *models.ServerSettings) error {
|
|||||||
}
|
}
|
||||||
for name, content := range files {
|
for name, content := range files {
|
||||||
path := filepath.Join(userconfigDir, name)
|
path := filepath.Join(userconfigDir, name)
|
||||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
if err := writeFileAtomic(path, []byte(content)); err != nil {
|
||||||
return fmt.Errorf("write %s: %w", name, err)
|
return fmt.Errorf("write %s: %w", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -983,3 +983,29 @@ func TestStart_StubWritesRPTLog(t *testing.T) {
|
|||||||
pm.Stop()
|
pm.Stop()
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestart(t *testing.T) {
|
||||||
|
pm, _ := setupStubPM(t, "-t 10")
|
||||||
|
|
||||||
|
if err := pm.Start(); err != nil {
|
||||||
|
t.Fatalf("Start() error = %v", err)
|
||||||
|
}
|
||||||
|
if !pm.IsRunning() {
|
||||||
|
t.Fatal("IsRunning() should be true after Start()")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pm.Restart(); err != nil {
|
||||||
|
t.Fatalf("Restart() error = %v", err)
|
||||||
|
}
|
||||||
|
if !pm.IsRunning() {
|
||||||
|
t.Fatal("IsRunning() should be true after Restart()")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pm.Stop(); err != nil {
|
||||||
|
t.Fatalf("Stop() error = %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if pm.IsRunning() {
|
||||||
|
t.Fatal("IsRunning() should be false after Stop()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,29 @@ import (
|
|||||||
"arma3-web-server/internal/models"
|
"arma3-web-server/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func writeFileAtomic(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type SettingsManager struct {
|
type SettingsManager struct {
|
||||||
path string
|
path string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -49,7 +72,45 @@ func (sm *SettingsManager) Save(s *models.ServerSettings) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(sm.path, data, 0644)
|
return writeFileAtomic(sm.path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SettingsManager) Update(fn func(*models.ServerSettings)) (*models.ServerSettings, error) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(sm.path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
s := sm.defaults()
|
||||||
|
fn(s)
|
||||||
|
s.UpdatedAt = time.Now().UTC()
|
||||||
|
out, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(sm.path, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var s models.ServerSettings
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fn(&s)
|
||||||
|
s.UpdatedAt = time.Now().UTC()
|
||||||
|
out, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(sm.path, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SettingsManager) defaults() *models.ServerSettings {
|
func (sm *SettingsManager) defaults() *models.ServerSettings {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ type SteamCmdManager struct {
|
|||||||
serverfileDir string
|
serverfileDir string
|
||||||
streamer *LogStreamer
|
streamer *LogStreamer
|
||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
cancel context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager {
|
func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager {
|
||||||
@@ -115,15 +114,13 @@ func (s *SteamCmdManager) run(label string, args []string) error {
|
|||||||
return fmt.Errorf("start steamcmd: %w", err)
|
return fmt.Errorf("start steamcmd: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.cancel = cancel
|
|
||||||
|
|
||||||
go s.streamer.Stream(ctx, "steamcmd", stdout, "")
|
go s.streamer.Stream(ctx, "steamcmd", stdout, "")
|
||||||
go s.streamer.Stream(ctx, "steamcmd", stderr, "")
|
go s.streamer.Stream(ctx, "steamcmd", stderr, "")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
|
cancel()
|
||||||
s.running.Store(false)
|
s.running.Store(false)
|
||||||
s.cancel = nil
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished")
|
s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user