package services import ( "context" "fmt" "net" "os" "os/exec" "path/filepath" "strings" "sync" "arma3-web-server/internal/models" ) type ProcessManager struct { serverfileDir string modsDir string cfgDir string profilesDir string settings *SettingsManager modlists *ModlistManager configs *ConfigManager streamer *LogStreamer proc *runningProcess mu sync.RWMutex } type runningProcess struct { cmd *exec.Cmd cancel context.CancelFunc exited chan struct{} } func NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir string, settings *SettingsManager, modlists *ModlistManager, configs *ConfigManager, streamer *LogStreamer) *ProcessManager { return &ProcessManager{ serverfileDir: serverfileDir, modsDir: modsDir, cfgDir: cfgDir, profilesDir: profilesDir, settings: settings, modlists: modlists, configs: configs, streamer: streamer, } } func (pm *ProcessManager) ServerfileDir() string { return pm.serverfileDir } func (pm *ProcessManager) ProfilesDir() string { return pm.profilesDir } func (pm *ProcessManager) IsRunning() bool { pm.mu.RLock() defer pm.mu.RUnlock() return pm.proc != nil } func (pm *ProcessManager) Start() error { pm.mu.Lock() if pm.proc != nil { pm.mu.Unlock() return fmt.Errorf("server already running") } pm.mu.Unlock() s, err := pm.settings.Load() if err != nil { return fmt.Errorf("load settings: %w", err) } s.WasRunning = true if err := pm.settings.Save(s); err != nil { return fmt.Errorf("save was_running: %w", err) } 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) parts := splitArgs(full) if len(parts) == 0 { cancel() return fmt.Errorf("empty command after %%command%% substitution") } autoArgs := pm.buildAutoArgs(s) cmd = exec.CommandContext(ctx, parts[0], append(parts[1:], autoArgs...)...) } else { cmd = exec.CommandContext(ctx, binPath, pm.buildArgs(s)...) } cmd.Dir = pm.serverfileDir stdout, err := cmd.StdoutPipe() if err != nil { cancel() return fmt.Errorf("stdout pipe: %w", err) } stderr, err := cmd.StderrPipe() if err != nil { cancel() return fmt.Errorf("stderr pipe: %w", err) } if err := cmd.Start(); err != nil { cancel() return fmt.Errorf("start: %w", err) } exited := make(chan struct{}) pm.mu.Lock() pm.proc = &runningProcess{cmd: cmd, cancel: cancel, exited: exited} pm.mu.Unlock() go pm.streamer.Stream("server", stdout, "") go pm.streamer.Stream("server", stderr, "") go func() { cmd.Wait() pm.mu.Lock() pm.proc = nil pm.mu.Unlock() close(exited) pm.streamer.Broadcast("server", "[SERVER_PROCESS_EXITED]") }() return nil } func (pm *ProcessManager) Stop() error { pm.mu.Lock() rp := pm.proc pm.mu.Unlock() if rp == nil { return fmt.Errorf("server not running") } rp.cancel() <-rp.exited s, err := pm.settings.Load() if err == nil { s.WasRunning = false pm.settings.Save(s) } return nil } func (pm *ProcessManager) Restart() error { _ = pm.Stop() return pm.Start() } func splitArgs(raw string) []string { var args []string for _, part := range strings.Fields(raw) { part = strings.TrimSpace(part) if part != "" { args = append(args, part) } } return args } func (pm *ProcessManager) buildAutoArgs(s *models.ServerSettings) []string { var args []string if s.ActiveConfig != "" { cfgPath := filepath.Join(pm.cfgDir, s.ActiveConfig+".cfg") args = append(args, "-config="+cfgPath) } if s.ActiveModlist != "" { modPath := pm.buildModPath(s.ActiveModlist) if modPath != "" { args = append(args, modPath) } } if pm.profilesDir != "" && !hasArgPrefix(args, "-profiles=") { args = append(args, "-profiles="+pm.profilesDir) } if s.IPPort != "" && !hasArgPrefix(args, "-port=") { if _, portStr, err := net.SplitHostPort(s.IPPort); err == nil && portStr != "" { args = append(args, "-port="+portStr) } } return args } func (pm *ProcessManager) buildArgs(s *models.ServerSettings) []string { args := splitArgs(s.ServerParameters) args = append(args, pm.buildAutoArgs(s)...) return args } func hasArgPrefix(args []string, prefix string) bool { for _, a := range args { if strings.HasPrefix(a, prefix) { return true } } return false } const workshopAppID = "107410" func (pm *ProcessManager) buildModPath(modlistID string) string { ml, err := pm.modlists.Get(modlistID) if err != nil { return "" } var parts []string for _, mod := range ml.Mods { if !mod.Enabled { continue } parts = append(parts, pm.resolveModPath(mod)) } if len(parts) == 0 { return "" } return "-mod=" + strings.Join(parts, ";") } func (pm *ProcessManager) resolveModPath(mod models.ModEntry) string { if mod.Name != "" { p := filepath.Join(pm.modsDir, "@"+mod.Name) if _, err := os.Stat(p); err == nil { return p } } if mod.ID != "" { p := filepath.Join(pm.serverfileDir, "steamapps", "workshop", "content", workshopAppID, mod.ID) if _, err := os.Stat(p); err == nil { return p } } if mod.Name != "" { return filepath.Join(pm.modsDir, "@"+mod.Name) } return "" } func (pm *ProcessManager) WriteUserconfigFiles(s *models.ServerSettings) error { userconfigDir := filepath.Join(pm.serverfileDir, "userconfig") if err := os.MkdirAll(userconfigDir, 0755); err != nil { return err } files := map[string]string{ "cba_settings.sqf": s.CBASettings, "CfgAILevelPresets.sqf": s.AILevelPresets, "CfgDifficultyPresets.sqf": s.DifficultyPresets, } for name, content := range files { path := filepath.Join(userconfigDir, name) if err := os.WriteFile(path, []byte(content), 0644); err != nil { return fmt.Errorf("write %s: %w", name, err) } } return nil }