From 975829ed7062542db7c91a4c37d81d1ee7105ff1 Mon Sep 17 00:00:00 2001 From: mixeme Date: Thu, 18 Jun 2026 00:13:12 +0300 Subject: [PATCH] Improve command execution modes --- src/core/model.go | 25 ++-- src/core/runner.go | 205 +++++++++++++++++++++++++++----- src/core/runner_other.go | 11 +- src/core/runner_test.go | 237 +++++++++++++++++++++++++++++++++++++ src/core/runner_windows.go | 59 ++++++++- src/core/scheduler.go | 19 +++ src/core/scheduler_test.go | 25 ++++ src/core/store.go | 5 + src/core/version.go | 2 +- src/gui/app.go | 63 +++++++++- 10 files changed, 600 insertions(+), 51 deletions(-) diff --git a/src/core/model.go b/src/core/model.go index 59a2eec..2c023de 100644 --- a/src/core/model.go +++ b/src/core/model.go @@ -32,17 +32,20 @@ type JobsFile struct { // while GoSentry is running, but writing them to jobs.yaml would make the jobs // file noisy and would mix durable configuration with transient execution state. type Job struct { - ID int `yaml:"id"` - Name string `yaml:"name"` - Folder string `yaml:"folder,omitempty"` - Schedule string `yaml:"schedule"` - Command string `yaml:"command"` - Enabled bool `yaml:"enabled"` - LastRun string `yaml:"-"` - NextRun string `yaml:"-"` - LastState string `yaml:"-"` - Logs []RunRecord `yaml:"-"` - Output string `yaml:"-"` + ID int `yaml:"id"` + Name string `yaml:"name"` + Folder string `yaml:"folder,omitempty"` + Schedule string `yaml:"schedule"` + Command string `yaml:"command"` + Arguments string `yaml:"arguments,omitempty"` + SuccessExitCodes string `yaml:"success_exit_codes,omitempty"` + StartOnly bool `yaml:"start_only,omitempty"` + Enabled bool `yaml:"enabled"` + LastRun string `yaml:"-"` + NextRun string `yaml:"-"` + LastState string `yaml:"-"` + Logs []RunRecord `yaml:"-"` + Output string `yaml:"-"` // nextDue is kept as time.Time for scheduler comparisons. The formatted // NextRun string above exists only for display in the GUI and YAML rewriting diff --git a/src/core/runner.go b/src/core/runner.go index a336f72..e70fd4a 100644 --- a/src/core/runner.go +++ b/src/core/runner.go @@ -8,14 +8,15 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "sort" + "strconv" "strings" "time" "unicode" ) const commandTimeout = 30 * time.Second +const commandWaitDelay = 2 * time.Second func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord { started := time.Now() @@ -26,30 +27,28 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe runCtx, cancel := context.WithTimeout(ctx, commandTimeout) defer cancel() - // The command is executed through the platform shell so users can type the - // same command they would test manually in cmd.exe or sh. This is less strict - // than argv-based execution, but it is the expected behavior for a cron-like - // tool that supports redirection, environment expansion, and shell builtins. - command := shellCommand(runCtx, job.Command) - configureHiddenWindow(command) var stdout bytes.Buffer var stderr bytes.Buffer - command.Stdout = &stdout - command.Stderr = &stderr - - err := command.Run() - duration := time.Since(started).Round(time.Millisecond) - output := formatOutput(stdout.String(), stderr.String()) - - state := "OK" - detail := fmt.Sprintf("Completed in %s", duration) - if err != nil { - state = "Failed" - if errors.Is(runCtx.Err(), context.DeadlineExceeded) { - detail = fmt.Sprintf("Timed out after %s", commandTimeout) - } else { - detail = err.Error() + var output string + var state string + var detail string + if job.StartOnly { + invocation := jobInvocation(context.Background(), *job) + state, detail, output = startJobOnly(invocation, *job, started) + } else { + invocation := jobInvocation(runCtx, *job) + command := invocation.command + command.WaitDelay = commandWaitDelay + if invocation.hideWindow { + configureHiddenWindow(command) } + command.Stdout = &stdout + command.Stderr = &stderr + + err := command.Run() + duration := time.Since(started).Round(time.Millisecond) + output = formatOutput(stdout.String(), stderr.String()) + state, detail = runStateDetail(err, runCtx.Err(), duration, *job) } now := time.Now() @@ -141,8 +140,8 @@ func writeRunLog(logsDir string, job Job, trigger string, state string, detail s // avoid characters that are invalid on Windows or awkward on shells. fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log" path := filepath.Join(logsDir, fileName) - content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\n\n%s\n", - started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, output) + content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n", + started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return "" } @@ -172,15 +171,157 @@ func sanitizeFileName(name string) string { return result } -func shellCommand(ctx context.Context, command string) *exec.Cmd { - if runtime.GOOS == "windows" { - // cmd.exe /C preserves Windows users' expectations for commands such as - // "dir", "copy", variable expansion, and .bat/.cmd wrappers. - return exec.CommandContext(ctx, "cmd.exe", "/C", command) +func startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) { + command := invocation.command + if invocation.hideWindow { + configureHiddenWindow(command) } - // sh -c is the portable baseline for Linux builds. It keeps the runner small - // and avoids a hard dependency on a larger shell such as bash. - return exec.CommandContext(ctx, "sh", "-c", command) + err := command.Start() + duration := time.Since(started).Round(time.Millisecond) + if err != nil { + return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0) + } + pid := command.Process.Pid + if releaseErr := command.Process.Release(); releaseErr != nil { + return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid) + } + return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid) +} + +func startOnlyOutput(job Job, pid int) string { + var builder strings.Builder + builder.WriteString("status:\n") + if pid > 0 { + builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid)) + } else { + builder.WriteString("Process did not start.\n\n") + } + builder.WriteString("command:\n") + builder.WriteString(job.Command + "\n\n") + builder.WriteString("arguments:\n") + builder.WriteString(logArguments(job.Arguments)) + builder.WriteString("\n\nstart_only:\ntrue") + return builder.String() +} + +func runStateDetail(err error, runErr error, duration time.Duration, job Job) (string, string) { + if err == nil { + return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration) + } + if errors.Is(runErr, context.DeadlineExceeded) { + return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout) + } + if errors.Is(err, exec.ErrWaitDelay) { + return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay) + } + + var exitError *exec.ExitError + if errors.As(err, &exitError) { + exitCode := exitError.ExitCode() + if acceptedExitCode(exitCode, job.SuccessExitCodes) { + return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode) + } + return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job)) + } + return "Failed", fmt.Sprintf("%T: %v", err, err) +} + +func acceptedExitCode(exitCode int, successExitCodes string) bool { + for _, accepted := range parseExitCodes(successExitCodes) { + if exitCode == accepted { + return true + } + } + return false +} + +func parseExitCodes(value string) []int { + value = strings.TrimSpace(value) + if value == "" { + return []int{0} + } + fields := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + result := make([]int, 0, len(fields)) + seen := map[int]bool{} + for _, field := range fields { + code, err := strconv.Atoi(strings.TrimSpace(field)) + if err != nil || seen[code] { + continue + } + seen[code] = true + result = append(result, code) + } + if len(result) == 0 { + return []int{0} + } + return result +} + +func successExitCodesText(job Job) string { + codes := parseExitCodes(job.SuccessExitCodes) + parts := make([]string, 0, len(codes)) + for _, code := range codes { + parts = append(parts, strconv.Itoa(code)) + } + return strings.Join(parts, ",") +} + +type commandInvocation struct { + command *exec.Cmd + hideWindow bool +} + +func jobInvocation(ctx context.Context, job Job) commandInvocation { + command := strings.TrimSpace(job.Command) + arguments := commandArguments(job.Arguments) + if len(arguments) > 0 || commandPathExists(command) { + return commandInvocation{ + command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...), + hideWindow: false, + } + } + + // Shell mode remains for existing jobs and for commands that intentionally + // use builtins, redirection, variables, or chained command syntax. + return commandInvocation{ + command: shellCommand(ctx, command), + hideWindow: true, + } +} + +func commandArguments(arguments string) []string { + var result []string + for _, line := range strings.FieldsFunc(arguments, func(r rune) bool { + return r == '\n' || r == '\r' + }) { + line = strings.TrimSpace(line) + if line != "" { + result = append(result, line) + } + } + return result +} + +func commandPathExists(command string) bool { + command = unquoteCommandPath(strings.TrimSpace(command)) + if command == "" { + return false + } + info, err := os.Stat(command) + return err == nil && !info.IsDir() +} + +func unquoteCommandPath(command string) string { + return strings.Trim(strings.TrimSpace(command), `"`) +} + +func logArguments(arguments string) string { + if strings.TrimSpace(arguments) == "" { + return "" + } + return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n") } func formatOutput(stdout string, stderr string) string { diff --git a/src/core/runner_other.go b/src/core/runner_other.go index e57d7c4..5c94ead 100644 --- a/src/core/runner_other.go +++ b/src/core/runner_other.go @@ -2,7 +2,16 @@ package core -import "os/exec" +import ( + "context" + "os/exec" +) + +func shellCommand(ctx context.Context, command string) *exec.Cmd { + // sh -c is the portable baseline for Linux builds. It keeps the runner small + // and avoids a hard dependency on a larger shell such as bash. + return exec.CommandContext(ctx, "sh", "-c", command) +} func configureHiddenWindow(command *exec.Cmd) { // Non-Windows platforms do not create a new console window for sh -c from a diff --git a/src/core/runner_test.go b/src/core/runner_test.go index 671b328..11081bc 100644 --- a/src/core/runner_test.go +++ b/src/core/runner_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -38,3 +39,239 @@ func TestRunJobWritesLogFile(t *testing.T) { } } } + +func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows cmd.exe quoting only") + } + + logsDir := t.TempDir() + job := Job{ + ID: 43, + Name: "Quoted Windows Command", + Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`, + } + + record := RunJob(context.Background(), &job, "Manual", logsDir) + if record.State != "OK" { + t.Fatalf("expected quoted command to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output) + } + if !strings.Contains(record.Output, "quoted command ok") { + t.Fatalf("expected command output, got:\n%s", record.Output) + } +} + +func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows cmd.exe quoting only") + } + + logsDir := t.TempDir() + scriptDir := filepath.Join(t.TempDir(), "Program Files", "GoSentry Test") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatal(err) + } + scriptPath := filepath.Join(scriptDir, "hello.cmd") + if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil { + t.Fatal(err) + } + job := Job{ + ID: 44, + Name: "Unquoted Windows Command", + Command: scriptPath, + } + + record := RunJob(context.Background(), &job, "Manual", logsDir) + if record.State != "OK" { + t.Fatalf("expected unquoted command path to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output) + } + if !strings.Contains(record.Output, "unquoted command ok") { + t.Fatalf("expected command output, got:\n%s", record.Output) + } +} + +func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows command arguments only") + } + + logsDir := t.TempDir() + job := Job{ + ID: 45, + Name: "Separate Arguments", + Command: `C:\Windows\System32\cmd.exe`, + Arguments: "/C\necho separate arguments ok", + } + + record := RunJob(context.Background(), &job, "Manual", logsDir) + if record.State != "OK" { + t.Fatalf("expected separate arguments to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output) + } + if !strings.Contains(record.Output, "separate arguments ok") { + t.Fatalf("expected command output, got:\n%s", record.Output) + } +} + +func TestRunJobAcceptsConfiguredExitCode(t *testing.T) { + command := `sh -c 'exit 1'` + if runtime.GOOS == "windows" { + command = `C:\Windows\System32\cmd.exe` + } + job := Job{ + ID: 46, + Name: "Accepted Exit Code", + Command: command, + SuccessExitCodes: "0,1", + } + if runtime.GOOS == "windows" { + job.Arguments = "/C\nexit /b 1" + } + + record := RunJob(context.Background(), &job, "Manual", t.TempDir()) + if record.State != "OK" { + t.Fatalf("expected accepted exit code to be OK, got state %q detail %q", record.State, record.Detail) + } + if !strings.Contains(record.Detail, "accepted exit code 1") { + t.Fatalf("expected accepted exit code detail, got %q", record.Detail) + } +} + +func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) { + command := `sh -c 'exit 1'` + if runtime.GOOS == "windows" { + command = `C:\Windows\System32\cmd.exe` + } + job := Job{ + ID: 47, + Name: "Rejected Exit Code", + Command: command, + SuccessExitCodes: "0", + } + if runtime.GOOS == "windows" { + job.Arguments = "/C\nexit /b 1" + } + + record := RunJob(context.Background(), &job, "Manual", t.TempDir()) + if record.State != "Failed" { + t.Fatalf("expected rejected exit code to fail, got state %q detail %q", record.State, record.Detail) + } + if !strings.Contains(record.Detail, "Exit code 1") { + t.Fatalf("expected exit code detail, got %q", record.Detail) + } +} + +func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) { + command := "sh" + arguments := "-c\nexit 7" + if runtime.GOOS == "windows" { + command = `C:\Windows\System32\cmd.exe` + arguments = "/C\nexit /b 7" + } + job := Job{ + ID: 48, + Name: "Start Only", + Command: command, + Arguments: arguments, + StartOnly: true, + } + + record := RunJob(context.Background(), &job, "Manual", t.TempDir()) + if record.State != "OK" { + t.Fatalf("expected start-only job to be OK after launch, got state %q detail %q", record.State, record.Detail) + } + if !strings.Contains(record.Detail, "not waiting for process exit") { + t.Fatalf("expected start-only detail, got %q", record.Detail) + } + if !strings.Contains(record.Output, "start_only:\ntrue") { + t.Fatalf("expected start-only output, got:\n%s", record.Output) + } +} + +func TestRunJobStartOnlyReportsStartFailure(t *testing.T) { + job := Job{ + ID: 49, + Name: "Missing Start Only", + Command: "definitely-missing-gosentry-command", + Arguments: "--force-direct-start", + StartOnly: true, + } + + record := RunJob(context.Background(), &job, "Manual", t.TempDir()) + if record.State != "Failed" { + t.Fatalf("expected missing start-only command to fail, got state %q detail %q", record.State, record.Detail) + } + if !strings.Contains(record.Output, "Process did not start") { + t.Fatalf("expected start failure output, got:\n%s", record.Output) + } +} + +func TestParseExitCodes(t *testing.T) { + got := parseExitCodes("0, 1;2\n3") + want := []int{0, 1, 2, 3} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("expected %v, got %v", want, got) + } + } +} + +func TestDirectCommandDoesNotHideWindow(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows window visibility only") + } + + invocation := jobInvocation(context.Background(), Job{ + Command: `C:\Windows\System32\cmd.exe`, + Arguments: "/C\necho visible direct process", + }) + if invocation.hideWindow { + t.Fatal("direct command should not request hidden startup window") + } +} + +func TestShellCommandHidesWindow(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows window visibility only") + } + + invocation := jobInvocation(context.Background(), Job{Command: "echo hidden shell process"}) + if !invocation.hideWindow { + t.Fatal("shell command should request hidden startup window") + } + configureHiddenWindow(invocation.command) + if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow { + t.Fatal("expected shell command to be hidden") + } +} + +func TestShellCommandUsesWindowsSafeQuoting(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows cmd.exe quoting only") + } + + command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`) + configureHiddenWindow(command) + + want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""` + if command.SysProcAttr == nil { + t.Fatal("expected SysProcAttr") + } + if command.SysProcAttr.CmdLine != want { + t.Fatalf("expected command line %q, got %q", want, command.SysProcAttr.CmdLine) + } +} + +func TestWindowsShellCommandLineQuotesUnquotedProgramPath(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows cmd.exe quoting only") + } + + got := windowsShellCommandLine(`C:\Program Files\Joplin\Joplin.exe --profile "D:\Joplin Profile"`) + want := `cmd.exe /S /C ""C:\Program Files\Joplin\Joplin.exe" --profile "D:\Joplin Profile""` + if got != want { + t.Fatalf("expected command line %q, got %q", want, got) + } +} diff --git a/src/core/runner_windows.go b/src/core/runner_windows.go index 52144d5..fa37fff 100644 --- a/src/core/runner_windows.go +++ b/src/core/runner_windows.go @@ -1,16 +1,69 @@ package core import ( + "context" "os/exec" + "strings" "syscall" + "unicode" ) +func shellCommand(ctx context.Context, command string) *exec.Cmd { + // cmd.exe keeps Windows users' expectations for commands such as "dir", + // "copy", variable expansion, redirection, and .bat/.cmd wrappers. + // + // Go's normal Windows argument escaping turns embedded quotes into literal + // backslash-quote sequences for cmd.exe. Supplying the raw command line keeps + // commands like `"C:\Program Files\App\App.exe" "D:\file.txt"` executable. + result := exec.CommandContext(ctx, "cmd.exe") + result.SysProcAttr = &syscall.SysProcAttr{CmdLine: windowsShellCommandLine(command)} + return result +} + +func windowsShellCommandLine(command string) string { + return `cmd.exe /S /C "` + quoteLeadingWindowsProgramPath(command) + `"` +} + +func quoteLeadingWindowsProgramPath(command string) string { + trimmed := strings.TrimLeftFunc(command, unicode.IsSpace) + leadingWhitespace := command[:len(command)-len(trimmed)] + if trimmed == "" || strings.HasPrefix(trimmed, `"`) || !startsWithWindowsRootedPath(trimmed) { + return command + } + + lower := strings.ToLower(trimmed) + for _, extension := range []string{".exe", ".cmd", ".bat", ".com"} { + index := strings.Index(lower, extension) + if index < 0 { + continue + } + pathEnd := index + len(extension) + programPath := trimmed[:pathEnd] + if !strings.ContainsFunc(programPath, unicode.IsSpace) { + return command + } + return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:] + } + return command +} + +func startsWithWindowsRootedPath(command string) bool { + if strings.HasPrefix(command, `\\`) { + return true + } + return len(command) >= 3 && + ((command[0] >= 'A' && command[0] <= 'Z') || (command[0] >= 'a' && command[0] <= 'z')) && + command[1] == ':' && + (command[2] == '\\' || command[2] == '/') +} + func configureHiddenWindow(command *exec.Cmd) { // GoSentry is a GUI scheduler, so child commands should not flash a console // window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools // quiet while stdout/stderr are still captured through pipes. - command.SysProcAttr = &syscall.SysProcAttr{ - CreationFlags: 0x08000000, - HideWindow: true, + if command.SysProcAttr == nil { + command.SysProcAttr = &syscall.SysProcAttr{} } + command.SysProcAttr.CreationFlags |= 0x08000000 + command.SysProcAttr.HideWindow = true } diff --git a/src/core/scheduler.go b/src/core/scheduler.go index a5cfca9..004bec4 100644 --- a/src/core/scheduler.go +++ b/src/core/scheduler.go @@ -2,6 +2,7 @@ package core import ( "context" + "fmt" "strings" "sync" "time" @@ -148,6 +149,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool { jobCopy := *job job.LastState = "Running" job.NextRun = "Running" + job.Output = runningOutput(jobCopy, trigger, time.Now()) job.nextDue = time.Time{} _ = s.store.SaveJobs(*s.jobs) @@ -185,6 +187,23 @@ func (s *Scheduler) findJobByIDLocked(id int) *Job { return nil } +func runningOutput(job Job, trigger string, started time.Time) string { + var builder strings.Builder + builder.WriteString("status:\n") + builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n") + builder.WriteString("trigger:\n") + builder.WriteString(trigger + "\n\n") + builder.WriteString("command:\n") + builder.WriteString(job.Command + "\n\n") + builder.WriteString("arguments:\n") + builder.WriteString(logArguments(job.Arguments)) + builder.WriteString("\n\nsuccess_exit_codes:\n") + builder.WriteString(successExitCodesText(job)) + builder.WriteString("\n\nstart_only:\n") + builder.WriteString(fmt.Sprintf("%t", job.StartOnly)) + return builder.String() +} + func (s *Scheduler) resetNextRuns(now time.Time) { for index := range *s.jobs { job := &(*s.jobs)[index] diff --git a/src/core/scheduler_test.go b/src/core/scheduler_test.go index 066076f..ed0267d 100644 --- a/src/core/scheduler_test.go +++ b/src/core/scheduler_test.go @@ -1,6 +1,7 @@ package core import ( + "strings" "testing" "time" ) @@ -27,3 +28,27 @@ func TestNextRunTimeSupportsCron(t *testing.T) { t.Fatalf("expected %s, got %s", want, next) } } + +func TestRunningOutputIncludesInvocation(t *testing.T) { + started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local) + job := Job{ + Name: "Backup", + Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`, + Arguments: `D:\Local\Jobs\Auto.ffs_batch`, + SuccessExitCodes: "0,1", + } + + output := runningOutput(job, "Manual", started) + for _, want := range []string{ + "Running since 2026-06-17 23:40:00", + "Manual", + job.Command, + job.Arguments, + "0,1", + "start_only", + } { + if !strings.Contains(output, want) { + t.Fatalf("expected running output to contain %q, got:\n%s", want, output) + } + } +} diff --git a/src/core/store.go b/src/core/store.go index 5ba0e60..ce347f9 100644 --- a/src/core/store.go +++ b/src/core/store.go @@ -163,6 +163,11 @@ func normalizeJobs(jobs []Job) { // gives the user something observable and harmless instead. job.Command = echoCommand("GoSentry job ran") } + job.Arguments = strings.TrimSpace(job.Arguments) + job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes) + if job.SuccessExitCodes == "" { + job.SuccessExitCodes = "0" + } if job.LastRun == "" { job.LastRun = "Never" } diff --git a/src/core/version.go b/src/core/version.go index a5c9110..50c98b9 100644 --- a/src/core/version.go +++ b/src/core/version.go @@ -3,4 +3,4 @@ package core // Version is the application version shown in the GUI and used by build // scripts in artifact names. It is a var rather than a const so release builds // can override it with Go ldflags when CI tags a build. -var Version = "0.3.1" +var Version = "0.3.2" diff --git a/src/gui/app.go b/src/gui/app.go index 4e6c50f..c86ccf9 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -181,6 +181,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { folder := newJobDetailLabel(jobs[selected].Folder) schedule := newJobDetailLabel(jobs[selected].Schedule) command := newJobDetailLabel(jobs[selected].Command) + arguments := newJobDetailLabel(jobs[selected].Arguments) + successExitCodes := newJobDetailLabel(displaySuccessExitCodes(jobs[selected].SuccessExitCodes)) + runMode := newJobDetailLabel(displayRunMode(jobs[selected])) lastRun := newJobDetailLabel(jobs[selected].LastRun) nextRun := newJobDetailLabel(jobs[selected].NextRun) state := newJobDetailLabel(jobs[selected].LastState) @@ -224,6 +227,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { folder.SetText("") schedule.SetText("") command.SetText("") + arguments.SetText("") + successExitCodes.SetText("") + runMode.SetText("") lastRun.SetText("") nextRun.SetText("") state.SetText("") @@ -237,6 +243,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { folder.SetText(displayFolder(current.Folder)) schedule.SetText(current.Schedule) command.SetText(current.Command) + arguments.SetText(displayArguments(current.Arguments)) + successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes)) + runMode.SetText(displayRunMode(current)) lastRun.SetText(current.LastRun) nextRun.SetText(current.NextRun) state.SetText(current.LastState) @@ -272,7 +281,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { name.SetText(current.Name) // Keep each row compact: folder, schedule, and command are shown in one // metadata line so the left pane stays useful even with many jobs. - meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + current.Command) + meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + displayInvocation(current)) status.SetText(statusText(current)) }, ) @@ -478,6 +487,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { detailRow("Folder", folder), detailRow("Schedule", schedule), detailRow("Command", command), + detailRow("Arguments", arguments), + detailRow("Success exit codes", successExitCodes), + detailRow("Run mode", runMode), detailRow("Last run", lastRun), detailRow("Next run", nextRun), detailRow("State", state), @@ -664,6 +676,34 @@ func displayFolder(folder string) string { return strings.TrimSpace(folder) } +func displayArguments(arguments string) string { + if strings.TrimSpace(arguments) == "" { + return "(none)" + } + return strings.TrimSpace(arguments) +} + +func displaySuccessExitCodes(codes string) string { + if strings.TrimSpace(codes) == "" { + return "0" + } + return strings.TrimSpace(codes) +} + +func displayRunMode(current job) string { + if current.StartOnly { + return "Start only" + } + return "Wait for completion" +} + +func displayInvocation(current job) string { + if strings.TrimSpace(current.Arguments) == "" { + return current.Command + } + return current.Command + " " + strings.ReplaceAll(strings.TrimSpace(current.Arguments), "\n", " ") +} + func displayIndex(indexes []int, jobIndex int) int { for display, index := range indexes { if index == jobIndex { @@ -684,8 +724,16 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { schedule.SetPlaceHolder("@every 1m") schedule.SetText(current.Schedule) command := widget.NewEntry() - command.SetPlaceHolder("echo GoSentry job ran") + command.SetPlaceHolder(`C:\Program Files\App\App.exe`) command.SetText(current.Command) + arguments := widget.NewMultiLineEntry() + arguments.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`) + arguments.SetText(current.Arguments) + successExitCodes := widget.NewEntry() + successExitCodes.SetPlaceHolder("0") + successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes)) + startOnly := widget.NewCheck("Start only, do not wait for exit", nil) + startOnly.SetChecked(current.StartOnly) enabled := widget.NewCheck("Enabled", nil) enabled.SetChecked(current.Enabled) @@ -698,6 +746,9 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { widget.NewFormItem("Folder", folder), widget.NewFormItem("Schedule", schedule), widget.NewFormItem("Command", command), + widget.NewFormItem("Arguments", arguments), + widget.NewFormItem("Success exit codes", successExitCodes), + widget.NewFormItem("", startOnly), widget.NewFormItem("", enabled), }, func(saved bool) { @@ -714,6 +765,12 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { current.Folder = strings.TrimSpace(folder.Text) current.Schedule = strings.TrimSpace(schedule.Text) current.Command = strings.TrimSpace(command.Text) + current.Arguments = strings.TrimSpace(arguments.Text) + current.SuccessExitCodes = strings.TrimSpace(successExitCodes.Text) + if current.SuccessExitCodes == "" { + current.SuccessExitCodes = "0" + } + current.StartOnly = startOnly.Checked current.Enabled = enabled.Checked if current.LastRun == "" { current.LastRun = "Never" @@ -731,7 +788,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { }, w, ) - form.Resize(fyne.NewSize(560, 280)) + form.Resize(fyne.NewSize(640, 460)) form.Show() }