From 6f3175ac9aee7aa70047c9853771e11a1e4bc8a2 Mon Sep 17 00:00:00 2001 From: MrFastwind Date: Fri, 24 Jul 2026 03:57:51 +0200 Subject: [PATCH] feat: add Wine Z: path prefix for Windows platform on Linux host - Add winePath helper that prepends Z: when platform is windows on Linux - Thread platform through buildModPath, buildAutoArgs, and Start() - Apply Z: prefix to -config=, -mod=, -profiles=, and %command% binary paths - Add tests for winePath, buildAutoArgs, and buildModPath with platform param - Fix formatting across services package with gofmt - Include all prior bug fixes from this branch --- .gitea/workflows/ci.yml | 4 +- CODEBASE.md | 7 +- Dockerfile | 2 - Dockerfile.goreleaser | 2 - Makefile | 10 +- PLAN.md | 3 +- backend/cmd/server/main.go | 4 +- backend/internal/api/health.go | 4 +- backend/internal/api/logs.go | 4 +- backend/internal/api/router.go | 51 ++++--- backend/internal/services/config_manager.go | 4 +- backend/internal/services/log_streamer.go | 4 +- backend/internal/services/mod_manager.go | 4 +- backend/internal/services/scheduler.go | 2 +- backend/internal/services/server_process.go | 20 ++- .../internal/services/server_process_test.go | 144 +++++++++++++++++- backend/internal/services/settings.go | 24 +-- docker-compose.yml | 1 - 18 files changed, 219 insertions(+), 75 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 589358c..0e36f44 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" + - 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 ci && npm run build && npm test + - run: cd frontend && npm test diff --git a/CODEBASE.md b/CODEBASE.md index 6d725d5..706dcff 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -69,8 +69,7 @@ arma3-web-server/ │ │ └── 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 @@ -116,8 +115,8 @@ arma3-web-server/ | `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 | ^6.6.3 | DOM assertion matchers | -| `jsdom` | test env | ^26.1.0 | Browser environment for tests | +| `@testing-library/jest-dom` | test matcher | ^7.0.0 | DOM assertion matchers | +| `jsdom` | test env | ^29.1.1 | Browser environment for tests | ## Architecture diff --git a/Dockerfile b/Dockerfile index 2b4561c..7532937 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index 7e13dd1..e0bccd9 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -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"] diff --git a/Makefile b/Makefile index 92a95f7..835c267 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ .PHONY: backend frontend build run clean dev test test-backend test-frontend # 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) +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 diff --git a/PLAN.md b/PLAN.md index 7d4d39b..740df59 100644 --- a/PLAN.md +++ b/PLAN.md @@ -79,8 +79,7 @@ arma3-web-server/ │ │ └── components/ │ │ ├── ConfigEditor.tsx # Monaco editor wrapper │ │ ├── LiveTerminal.tsx # xterm.js + auto-reconnect WebSocket -│ │ ├── Layout.tsx # Sidebar nav shell -│ │ └── ui/ # (empty — reserved for future shared UI primitives) +│ │ └── Layout.tsx # Sidebar nav shell │ ├── package.json │ └── vite.config.ts # Proxy /api + /ws to :8080 ├── data/ # Runtime data (mounted volume in Docker) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5aae182..cdb4623 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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 diff --git a/backend/internal/api/health.go b/backend/internal/api/health.go index 5f70ce8..2616297 100644 --- a/backend/internal/api/health.go +++ b/backend/internal/api/health.go @@ -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) diff --git a/backend/internal/api/logs.go b/backend/internal/api/logs.go index 7d05272..c3d240c 100644 --- a/backend/internal/api/logs.go +++ b/backend/internal/api/logs.go @@ -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 diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 86a8de3..0e6b7ef 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -7,18 +7,19 @@ import ( ) type Handler struct { - settings *services.SettingsManager - configs *services.ConfigManager - modlists *services.ModlistManager - process *services.ProcessManager - steamcmd *services.SteamCmdManager - scheduler *services.Scheduler - streamer *services.LogStreamer - dataDir string - serverfileDir string - modsDir string - cfgDir string - profilesDir string + settings *services.SettingsManager + configs *services.ConfigManager + modlists *services.ModlistManager + process *services.ProcessManager + steamcmd *services.SteamCmdManager + scheduler *services.Scheduler + streamer *services.LogStreamer + dataDir string + serverfileDir string + modsDir string + cfgDir string + profilesDir string + frontendServed bool } func New( @@ -30,20 +31,22 @@ func New( scheduler *services.Scheduler, streamer *services.LogStreamer, dataDir, serverfileDir, modsDir, cfgDir, profilesDir string, + frontendServed bool, ) *Handler { return &Handler{ - settings: settings, - configs: configs, - modlists: modlists, - process: process, - steamcmd: steamcmd, - scheduler: scheduler, - streamer: streamer, - dataDir: dataDir, - serverfileDir: serverfileDir, - modsDir: modsDir, - cfgDir: cfgDir, - profilesDir: profilesDir, + settings: settings, + configs: configs, + modlists: modlists, + process: process, + steamcmd: steamcmd, + scheduler: scheduler, + streamer: streamer, + dataDir: dataDir, + serverfileDir: serverfileDir, + modsDir: modsDir, + cfgDir: cfgDir, + profilesDir: profilesDir, + frontendServed: frontendServed, } } diff --git a/backend/internal/services/config_manager.go b/backend/internal/services/config_manager.go index 0a31067..a1823f3 100644 --- a/backend/internal/services/config_manager.go +++ b/backend/internal/services/config_manager.go @@ -16,8 +16,8 @@ func NewConfigManager(cfgDir string) *ConfigManager { } type ConfigInfo struct { - Name string `json:"name"` - Size int64 `json:"size"` + Name string `json:"name"` + Size int64 `json:"size"` } func (cm *ConfigManager) List() ([]ConfigInfo, error) { diff --git a/backend/internal/services/log_streamer.go b/backend/internal/services/log_streamer.go index c2c8770..bb2d606 100644 --- a/backend/internal/services/log_streamer.go +++ b/backend/internal/services/log_streamer.go @@ -8,8 +8,8 @@ import ( ) type LogStreamer struct { - mu sync.RWMutex - subs map[string]map[string]chan string + mu sync.RWMutex + subs map[string]map[string]chan string } func NewLogStreamer() *LogStreamer { diff --git a/backend/internal/services/mod_manager.go b/backend/internal/services/mod_manager.go index e61da97..b16ba37 100644 --- a/backend/internal/services/mod_manager.go +++ b/backend/internal/services/mod_manager.go @@ -60,8 +60,8 @@ func ListLocalMods(modsDir string) []ModInfo { displayName = name[1:] } mods = append(mods, ModInfo{ - ID: displayName, - Name: name, + ID: displayName, + Name: name, Source: "local", Path: p, Size: size, diff --git a/backend/internal/services/scheduler.go b/backend/internal/services/scheduler.go index edcd27d..60bce52 100644 --- a/backend/internal/services/scheduler.go +++ b/backend/internal/services/scheduler.go @@ -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) diff --git a/backend/internal/services/server_process.go b/backend/internal/services/server_process.go index 9015880..66c19b8 100644 --- a/backend/internal/services/server_process.go +++ b/backend/internal/services/server_process.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "sync/atomic" @@ -14,6 +15,13 @@ import ( "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 ( @@ -101,7 +109,7 @@ func (pm *ProcessManager) Start() error { 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() @@ -205,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=") { @@ -245,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 "" @@ -256,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 { diff --git a/backend/internal/services/server_process_test.go b/backend/internal/services/server_process_test.go index 194a851..ba6e86f 100644 --- a/backend/internal/services/server_process_test.go +++ b/backend/internal/services/server_process_test.go @@ -294,7 +294,7 @@ func TestBuildModPath_MultipleMods(t *testing.T) { {Name: "DisabledMod", Enabled: false}, }) - got := pm.buildModPath(ml.ID) + got := pm.buildModPath(ml.ID, "") if !strings.HasPrefix(got, "-mod=") { t.Errorf("expected -mod= prefix, got %q", got) } @@ -321,7 +321,7 @@ func TestBuildModPath_EmptyModlist(t *testing.T) { pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) - got := pm.buildModPath("nonexistent-id") + got := pm.buildModPath("nonexistent-id", "") if got != "" { t.Errorf("buildModPath(nonexistent) = %q, want empty", got) } @@ -338,8 +338,8 @@ func TestWriteUserconfigFiles(t *testing.T) { pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) s := &models.ServerSettings{ - CBASettings: "force = 1", - AILevelPresets: "preset1", + CBASettings: "force = 1", + AILevelPresets: "preset1", DifficultyPresets: "difficulty_normal", } @@ -396,6 +396,142 @@ func TestWriteUserconfigFiles_EmptyContent(t *testing.T) { } } +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") diff --git a/backend/internal/services/settings.go b/backend/internal/services/settings.go index 465d99b..c721e0e 100644 --- a/backend/internal/services/settings.go +++ b/backend/internal/services/settings.go @@ -54,18 +54,18 @@ func (sm *SettingsManager) Save(s *models.ServerSettings) error { func (sm *SettingsManager) defaults() *models.ServerSettings { return &models.ServerSettings{ - IPPort: "0.0.0.0:2302", - ServerParameters: "-server -world=empty -loadMissionToMemory -noPause", - SteamBranch: "stable", - SteamUser: "anonymous", - Platform: "linux", - ActiveConfig: "", - ActiveModlist: "", - AutoUpdateOnStartup: false, - AutoStartOnStartup: false, + IPPort: "0.0.0.0:2302", + ServerParameters: "-server -world=empty -loadMissionToMemory -noPause", + SteamBranch: "stable", + SteamUser: "anonymous", + Platform: "linux", + ActiveConfig: "", + ActiveModlist: "", + AutoUpdateOnStartup: false, + AutoStartOnStartup: false, AutoUpdateModsOnStartup: false, - WasRunning: false, - ScheduledUpdate: "", - UpdatedAt: time.Now().UTC(), + WasRunning: false, + ScheduledUpdate: "", + UpdatedAt: time.Now().UTC(), } } diff --git a/docker-compose.yml b/docker-compose.yml index 17376df..ecf67a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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