Improve command execution modes
This commit is contained in:
+14
-11
@@ -32,17 +32,20 @@ type JobsFile struct {
|
|||||||
// while GoSentry is running, but writing them to jobs.yaml would make the jobs
|
// 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.
|
// file noisy and would mix durable configuration with transient execution state.
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID int `yaml:"id"`
|
ID int `yaml:"id"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Folder string `yaml:"folder,omitempty"`
|
Folder string `yaml:"folder,omitempty"`
|
||||||
Schedule string `yaml:"schedule"`
|
Schedule string `yaml:"schedule"`
|
||||||
Command string `yaml:"command"`
|
Command string `yaml:"command"`
|
||||||
Enabled bool `yaml:"enabled"`
|
Arguments string `yaml:"arguments,omitempty"`
|
||||||
LastRun string `yaml:"-"`
|
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
|
||||||
NextRun string `yaml:"-"`
|
StartOnly bool `yaml:"start_only,omitempty"`
|
||||||
LastState string `yaml:"-"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Logs []RunRecord `yaml:"-"`
|
LastRun string `yaml:"-"`
|
||||||
Output 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
|
// nextDue is kept as time.Time for scheduler comparisons. The formatted
|
||||||
// NextRun string above exists only for display in the GUI and YAML rewriting
|
// NextRun string above exists only for display in the GUI and YAML rewriting
|
||||||
|
|||||||
+173
-32
@@ -8,14 +8,15 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
const commandTimeout = 30 * time.Second
|
const commandTimeout = 30 * time.Second
|
||||||
|
const commandWaitDelay = 2 * time.Second
|
||||||
|
|
||||||
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
|
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
|
||||||
started := time.Now()
|
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)
|
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||||
defer cancel()
|
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 stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
command.Stdout = &stdout
|
var output string
|
||||||
command.Stderr = &stderr
|
var state string
|
||||||
|
var detail string
|
||||||
err := command.Run()
|
if job.StartOnly {
|
||||||
duration := time.Since(started).Round(time.Millisecond)
|
invocation := jobInvocation(context.Background(), *job)
|
||||||
output := formatOutput(stdout.String(), stderr.String())
|
state, detail, output = startJobOnly(invocation, *job, started)
|
||||||
|
} else {
|
||||||
state := "OK"
|
invocation := jobInvocation(runCtx, *job)
|
||||||
detail := fmt.Sprintf("Completed in %s", duration)
|
command := invocation.command
|
||||||
if err != nil {
|
command.WaitDelay = commandWaitDelay
|
||||||
state = "Failed"
|
if invocation.hideWindow {
|
||||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
configureHiddenWindow(command)
|
||||||
detail = fmt.Sprintf("Timed out after %s", commandTimeout)
|
|
||||||
} else {
|
|
||||||
detail = err.Error()
|
|
||||||
}
|
}
|
||||||
|
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()
|
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.
|
// avoid characters that are invalid on Windows or awkward on shells.
|
||||||
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
||||||
path := filepath.Join(logsDir, fileName)
|
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",
|
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, output)
|
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 {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -172,15 +171,157 @@ func sanitizeFileName(name string) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
func startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) {
|
||||||
if runtime.GOOS == "windows" {
|
command := invocation.command
|
||||||
// cmd.exe /C preserves Windows users' expectations for commands such as
|
if invocation.hideWindow {
|
||||||
// "dir", "copy", variable expansion, and .bat/.cmd wrappers.
|
configureHiddenWindow(command)
|
||||||
return exec.CommandContext(ctx, "cmd.exe", "/C", command)
|
|
||||||
}
|
}
|
||||||
// sh -c is the portable baseline for Linux builds. It keeps the runner small
|
err := command.Start()
|
||||||
// and avoids a hard dependency on a larger shell such as bash.
|
duration := time.Since(started).Round(time.Millisecond)
|
||||||
return exec.CommandContext(ctx, "sh", "-c", command)
|
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 "<empty>"
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatOutput(stdout string, stderr string) string {
|
func formatOutput(stdout string, stderr string) string {
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
package core
|
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) {
|
func configureHiddenWindow(command *exec.Cmd) {
|
||||||
// Non-Windows platforms do not create a new console window for sh -c from a
|
// Non-Windows platforms do not create a new console window for sh -c from a
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,69 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"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) {
|
func configureHiddenWindow(command *exec.Cmd) {
|
||||||
// GoSentry is a GUI scheduler, so child commands should not flash a console
|
// 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
|
// window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools
|
||||||
// quiet while stdout/stderr are still captured through pipes.
|
// quiet while stdout/stderr are still captured through pipes.
|
||||||
command.SysProcAttr = &syscall.SysProcAttr{
|
if command.SysProcAttr == nil {
|
||||||
CreationFlags: 0x08000000,
|
command.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
HideWindow: true,
|
|
||||||
}
|
}
|
||||||
|
command.SysProcAttr.CreationFlags |= 0x08000000
|
||||||
|
command.SysProcAttr.HideWindow = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -148,6 +149,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
|
|||||||
jobCopy := *job
|
jobCopy := *job
|
||||||
job.LastState = "Running"
|
job.LastState = "Running"
|
||||||
job.NextRun = "Running"
|
job.NextRun = "Running"
|
||||||
|
job.Output = runningOutput(jobCopy, trigger, time.Now())
|
||||||
job.nextDue = time.Time{}
|
job.nextDue = time.Time{}
|
||||||
_ = s.store.SaveJobs(*s.jobs)
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
|
|
||||||
@@ -185,6 +187,23 @@ func (s *Scheduler) findJobByIDLocked(id int) *Job {
|
|||||||
return nil
|
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) {
|
func (s *Scheduler) resetNextRuns(now time.Time) {
|
||||||
for index := range *s.jobs {
|
for index := range *s.jobs {
|
||||||
job := &(*s.jobs)[index]
|
job := &(*s.jobs)[index]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -27,3 +28,27 @@ func TestNextRunTimeSupportsCron(t *testing.T) {
|
|||||||
t.Fatalf("expected %s, got %s", want, next)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -163,6 +163,11 @@ func normalizeJobs(jobs []Job) {
|
|||||||
// gives the user something observable and harmless instead.
|
// gives the user something observable and harmless instead.
|
||||||
job.Command = echoCommand("GoSentry job ran")
|
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 == "" {
|
if job.LastRun == "" {
|
||||||
job.LastRun = "Never"
|
job.LastRun = "Never"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,4 +3,4 @@ package core
|
|||||||
// Version is the application version shown in the GUI and used by build
|
// 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
|
// 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.
|
// can override it with Go ldflags when CI tags a build.
|
||||||
var Version = "0.3.1"
|
var Version = "0.3.2"
|
||||||
|
|||||||
+60
-3
@@ -181,6 +181,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
folder := newJobDetailLabel(jobs[selected].Folder)
|
folder := newJobDetailLabel(jobs[selected].Folder)
|
||||||
schedule := newJobDetailLabel(jobs[selected].Schedule)
|
schedule := newJobDetailLabel(jobs[selected].Schedule)
|
||||||
command := newJobDetailLabel(jobs[selected].Command)
|
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)
|
lastRun := newJobDetailLabel(jobs[selected].LastRun)
|
||||||
nextRun := newJobDetailLabel(jobs[selected].NextRun)
|
nextRun := newJobDetailLabel(jobs[selected].NextRun)
|
||||||
state := newJobDetailLabel(jobs[selected].LastState)
|
state := newJobDetailLabel(jobs[selected].LastState)
|
||||||
@@ -224,6 +227,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
folder.SetText("")
|
folder.SetText("")
|
||||||
schedule.SetText("")
|
schedule.SetText("")
|
||||||
command.SetText("")
|
command.SetText("")
|
||||||
|
arguments.SetText("")
|
||||||
|
successExitCodes.SetText("")
|
||||||
|
runMode.SetText("")
|
||||||
lastRun.SetText("")
|
lastRun.SetText("")
|
||||||
nextRun.SetText("")
|
nextRun.SetText("")
|
||||||
state.SetText("")
|
state.SetText("")
|
||||||
@@ -237,6 +243,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
folder.SetText(displayFolder(current.Folder))
|
folder.SetText(displayFolder(current.Folder))
|
||||||
schedule.SetText(current.Schedule)
|
schedule.SetText(current.Schedule)
|
||||||
command.SetText(current.Command)
|
command.SetText(current.Command)
|
||||||
|
arguments.SetText(displayArguments(current.Arguments))
|
||||||
|
successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes))
|
||||||
|
runMode.SetText(displayRunMode(current))
|
||||||
lastRun.SetText(current.LastRun)
|
lastRun.SetText(current.LastRun)
|
||||||
nextRun.SetText(current.NextRun)
|
nextRun.SetText(current.NextRun)
|
||||||
state.SetText(current.LastState)
|
state.SetText(current.LastState)
|
||||||
@@ -272,7 +281,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
name.SetText(current.Name)
|
name.SetText(current.Name)
|
||||||
// Keep each row compact: folder, schedule, and command are shown in one
|
// Keep each row compact: folder, schedule, and command are shown in one
|
||||||
// metadata line so the left pane stays useful even with many jobs.
|
// 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))
|
status.SetText(statusText(current))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -478,6 +487,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
detailRow("Folder", folder),
|
detailRow("Folder", folder),
|
||||||
detailRow("Schedule", schedule),
|
detailRow("Schedule", schedule),
|
||||||
detailRow("Command", command),
|
detailRow("Command", command),
|
||||||
|
detailRow("Arguments", arguments),
|
||||||
|
detailRow("Success exit codes", successExitCodes),
|
||||||
|
detailRow("Run mode", runMode),
|
||||||
detailRow("Last run", lastRun),
|
detailRow("Last run", lastRun),
|
||||||
detailRow("Next run", nextRun),
|
detailRow("Next run", nextRun),
|
||||||
detailRow("State", state),
|
detailRow("State", state),
|
||||||
@@ -664,6 +676,34 @@ func displayFolder(folder string) string {
|
|||||||
return strings.TrimSpace(folder)
|
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 {
|
func displayIndex(indexes []int, jobIndex int) int {
|
||||||
for display, index := range indexes {
|
for display, index := range indexes {
|
||||||
if index == jobIndex {
|
if index == jobIndex {
|
||||||
@@ -684,8 +724,16 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
schedule.SetPlaceHolder("@every 1m")
|
schedule.SetPlaceHolder("@every 1m")
|
||||||
schedule.SetText(current.Schedule)
|
schedule.SetText(current.Schedule)
|
||||||
command := widget.NewEntry()
|
command := widget.NewEntry()
|
||||||
command.SetPlaceHolder("echo GoSentry job ran")
|
command.SetPlaceHolder(`C:\Program Files\App\App.exe`)
|
||||||
command.SetText(current.Command)
|
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 := widget.NewCheck("Enabled", nil)
|
||||||
enabled.SetChecked(current.Enabled)
|
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("Folder", folder),
|
||||||
widget.NewFormItem("Schedule", schedule),
|
widget.NewFormItem("Schedule", schedule),
|
||||||
widget.NewFormItem("Command", command),
|
widget.NewFormItem("Command", command),
|
||||||
|
widget.NewFormItem("Arguments", arguments),
|
||||||
|
widget.NewFormItem("Success exit codes", successExitCodes),
|
||||||
|
widget.NewFormItem("", startOnly),
|
||||||
widget.NewFormItem("", enabled),
|
widget.NewFormItem("", enabled),
|
||||||
},
|
},
|
||||||
func(saved bool) {
|
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.Folder = strings.TrimSpace(folder.Text)
|
||||||
current.Schedule = strings.TrimSpace(schedule.Text)
|
current.Schedule = strings.TrimSpace(schedule.Text)
|
||||||
current.Command = strings.TrimSpace(command.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
|
current.Enabled = enabled.Checked
|
||||||
if current.LastRun == "" {
|
if current.LastRun == "" {
|
||||||
current.LastRun = "Never"
|
current.LastRun = "Never"
|
||||||
@@ -731,7 +788,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
},
|
},
|
||||||
w,
|
w,
|
||||||
)
|
)
|
||||||
form.Resize(fyne.NewSize(560, 280))
|
form.Resize(fyne.NewSize(640, 460))
|
||||||
form.Show()
|
form.Show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user