package main import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" "hamlog/internal/applog" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // keyAutostartPrograms holds the per-profile list of external programs OpsLog // launches on startup (JSON array of AutostartProgram). const keyAutostartPrograms = "autostart.programs" // AutostartProgram is one external application OpsLog can launch when it starts // — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile. type AutostartProgram struct { ID string `json:"id"` Name string `json:"name"` Path string `json:"path"` Args string `json:"args"` Enabled bool `json:"enabled"` } // AutostartLaunchResult reports what happened for one program when launching. type AutostartLaunchResult struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` // launched | already_running | missing | disabled | error Message string `json:"message"` } // GetAutostartPrograms returns the active profile's autostart list. func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) { out := []AutostartProgram{} if a.settings == nil { return out, nil } s, _ := a.settings.Get(a.ctx, keyAutostartPrograms) if strings.TrimSpace(s) == "" { return out, nil } if err := json.Unmarshal([]byte(s), &out); err != nil { return []AutostartProgram{}, nil } return out, nil } // SaveAutostartPrograms persists the autostart list for the active profile. func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error { if a.settings == nil { return fmt.Errorf("db not initialized") } b, err := json.Marshal(progs) if err != nil { return err } return a.settings.Set(a.ctx, keyAutostartPrograms, string(b)) } // BrowseExecutable opens a native file picker for choosing a program to launch. func (a *App) BrowseExecutable() (string, error) { return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{ Title: "Choose a program to launch on startup", Filters: []wruntime.FileFilter{ {DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"}, {DisplayName: "All files (*.*)", Pattern: "*.*"}, }, }) } // LaunchAutostartPrograms starts every enabled program that isn't already // running, returning a per-program result. Used at startup (best effort) and by // the "Launch now" button in settings. func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult { progs, _ := a.GetAutostartPrograms() running := runningProcessNames() out := make([]AutostartLaunchResult, 0, len(progs)) for _, p := range progs { if !p.Enabled { continue } out = append(out, launchProgram(p, running)) } return out } // LaunchAutostartProgram launches a single program by id on demand (the per-row // "launch now" action), regardless of its enabled flag. func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) { progs, err := a.GetAutostartPrograms() if err != nil { return AutostartLaunchResult{}, err } for _, p := range progs { if p.ID == id { return launchProgram(p, runningProcessNames()), nil } } return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id) } // launchProgram starts one program unless its executable is already running. func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult { res := AutostartLaunchResult{ID: p.ID, Name: p.Name} path := strings.TrimSpace(p.Path) if path == "" { res.Status, res.Message = "error", "no path configured" return res } if _, err := os.Stat(path); err != nil { res.Status, res.Message = "missing", "executable not found: "+path return res } // Skip if a process with the same executable name is already running, so we // never spawn a second copy of WSJT-X / JTAlert / etc. base := strings.ToLower(filepath.Base(path)) if running[base] { res.Status, res.Message = "already_running", base+" already running" return res } cmd := exec.Command(path, splitArgs(p.Args)...) cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD if err := cmd.Start(); err != nil { res.Status, res.Message = "error", err.Error() return res } // Don't wait on the child — it runs independently of OpsLog. Release the // handle so we don't accumulate zombies. go func() { _ = cmd.Wait() }() res.Status, res.Message = "launched", "started "+base return res } // splitArgs does a minimal shell-like split of an argument string, honouring // double quotes so a path with spaces stays one argument. func splitArgs(s string) []string { s = strings.TrimSpace(s) if s == "" { return nil } var args []string var cur strings.Builder inQuote := false for _, r := range s { switch { case r == '"': inQuote = !inQuote case r == ' ' && !inQuote: if cur.Len() > 0 { args = append(args, cur.String()) cur.Reset() } default: cur.WriteRune(r) } } if cur.Len() > 0 { args = append(args, cur.String()) } return args } // runningProcessNames returns the set of lowercase executable names currently // running, via the Windows `tasklist`. Best effort — on failure the set is // empty (we then just attempt to launch, which is acceptable). func runningProcessNames() map[string]bool { out := map[string]bool{} cmd := exec.Command("tasklist", "/FO", "CSV", "/NH") cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW data, err := cmd.Output() if err != nil { applog.Printf("autostart: tasklist failed: %v", err) return out } for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" { continue } // CSV row: "image.exe","PID",... — take the first quoted field. field := line if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") { field = line[1 : i+1] } field = strings.Trim(field, "\"") if field != "" { out[strings.ToLower(field)] = true } } return out }