diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d9c903 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1.22-bookworm + +# Fyne links against native desktop libraries, so the container must include a C +# compiler plus OpenGL/X11 headers. --no-install-recommends keeps the image from +# pulling in unrelated desktop packages that are not needed for compilation. +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + gcc \ + libgl1-mesa-dev \ + xorg-dev && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +# Copy module files first so Docker can cache downloaded dependencies while the +# application source changes. This makes repeated local builds much faster. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# CGO is required by Fyne. The first Linux package target is linux/amd64; other +# architectures can be added later as separate, explicit build targets. +ENV CGO_ENABLED=1 +ENV GOOS=linux +ENV GOARCH=amd64 + +# -trimpath removes host paths from the binary, and -s -w strips symbol/debug +# tables to keep the produced desktop executable smaller. +RUN go build -trimpath -ldflags "-s -w" -o /out/pysentry ./cmd/pysentry diff --git a/Dockerfile.linux b/Dockerfile.linux deleted file mode 100644 index 96db289..0000000 --- a/Dockerfile.linux +++ /dev/null @@ -1,22 +0,0 @@ -FROM golang:1.22-bookworm - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - gcc \ - libgl1-mesa-dev \ - xorg-dev && \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /src - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -ENV CGO_ENABLED=1 -ENV GOOS=linux -ENV GOARCH=amd64 - -RUN go build -trimpath -ldflags "-s -w" -o /out/pysentry ./cmd/pysentry diff --git a/README.md b/README.md index f2317cb..84d8dd2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ PySentry is a cross-platform desktop scheduler inspired by cron. It provides a native GUI for creating, grouping, pausing, running, and monitoring scheduled shell commands. +PySentry is being designed and implemented with assistance from OpenAI Codex. + ## Features - Native desktop GUI built with Fyne. @@ -32,6 +34,8 @@ Linux: On Debian/Ubuntu, the Linux dependencies are typically: ```bash +# Go builds the application, gcc is required by CGO/Fyne, and the OpenGL/X11 +# development packages provide the native desktop headers used by Fyne. sudo apt install golang gcc libgl1-mesa-dev xorg-dev ``` @@ -40,6 +44,9 @@ sudo apt install golang gcc libgl1-mesa-dev xorg-dev Windows: ```powershell +# Builds dist\windows\pysentry.exe. The script adds MSYS2 UCRT64 to PATH for +# this process only, embeds the Windows icon when windres is available, and uses +# the Windows GUI subsystem so no console window opens at startup. .\scripts\build-windows.bat ``` @@ -48,12 +55,14 @@ The Windows build is created as a GUI application, so it does not open a termina The binary is written to: ```text +# GUI executable produced by scripts\build-windows.bat. dist\windows\pysentry.exe ``` Linux: ```bash +# Make the helper executable once, then build a linux/amd64 Fyne binary. chmod +x ./scripts/build-linux.sh ./scripts/build-linux.sh ``` @@ -61,12 +70,15 @@ chmod +x ./scripts/build-linux.sh The binary is written to: ```text +# Linux executable produced by scripts/build-linux.sh. dist/linux/pysentry ``` Linux using Docker: ```bash +# Builds the same Linux binary inside Docker, useful from Windows hosts or CI +# where the native Linux/Fyne packages are not installed locally. chmod +x ./scripts/build-linux-docker.sh ./scripts/build-linux-docker.sh ``` @@ -74,6 +86,7 @@ chmod +x ./scripts/build-linux-docker.sh The binary is copied to: ```text +# Linux executable copied out of the Docker build image. dist\linux\pysentry ``` @@ -82,14 +95,21 @@ dist\linux\pysentry Windows: ```powershell +# Fyne requires CGO on Windows. MSYS2 UCRT64 provides the C compiler and native +# libraries used by the desktop backend. $env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path $env:CGO_ENABLED = '1' + +# go run starts the app from source. Use scripts\build-windows.bat when you need +# a standalone .exe without a console window. & 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry ``` Linux: ```bash +# CGO must stay enabled because the Fyne GUI links against native Linux desktop +# libraries. CGO_ENABLED=1 go run ./cmd/pysentry ``` @@ -100,11 +120,25 @@ PySentry creates its runtime files next to the executable by default. `pysentry.yaml` stores application settings: ```yaml +# Directory containing jobs.yaml. "." means "the folder where pysentry.exe lives"; +# an absolute path can be used when jobs should live elsewhere. jobs_dir: . + +# Directory for per-run command output logs. Relative paths are resolved against +# the program folder, just like jobs_dir. logs_dir: logs + +# Keep at most this many .log files after cleanup. Newest logs are preserved. max_log_files: 100 + +# Delete .log files older than this many days during cleanup. max_log_age_days: 30 + +# Closing the window hides it to the tray instead of stopping the scheduler. keep_running_in_tray: true + +# Reserved for desktop failure notifications; the setting is stored now so the +# UI and config format do not need to change when notifications are wired fully. notify_on_failure: true ``` @@ -112,17 +146,32 @@ notify_on_failure: true ```yaml jobs: + # A harmless sample job created on first run so the scheduler can be tested + # immediately. Runtime fields such as last run time, next run time, and command + # output are intentionally not stored here; they are displayed in the GUI and + # written to separate log files. - id: 1 + # Human-readable name shown in the jobs list and used in log file names. name: Hello scheduler + + # Optional grouping label. Omit it or leave it empty to put the job under + # the "No folder" filter. folder: Examples + + # Either @every with a Go duration, or a standard five-field cron expression. schedule: '@every 10s' + + # Command passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux. command: echo PySentry test job: scheduler is alive + + # Disabled jobs remain in jobs.yaml but are skipped by the scheduler. enabled: true ``` Command output is written to separate files under `logs_dir`. File names include the run timestamp and job name, for example: ```text +# Format: YYYYMMDD-HHMMSS_.log 20260614-224306_Hello_scheduler.log ``` @@ -131,6 +180,7 @@ Command output is written to separate files under `logs_dir`. File names include Fast interval schedules: ```text +# Go duration syntax after @every; useful for tests and simple intervals. @every 10s @every 5m @every 1h30m @@ -139,6 +189,7 @@ Fast interval schedules: Standard 5-field cron schedules: ```text +# Standard five-field cron: minute hour day-of-month month day-of-week. */5 * * * * every five minutes 0 2 * * * every day at 02:00 30 9 * * 1-5 weekdays at 09:30 diff --git a/assets/assets.go b/assets/assets.go index bb5d135..5b5dc37 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -6,9 +6,21 @@ import ( "fyne.io/fyne/v2" ) +// The application icon is embedded into the binary instead of being loaded from +// an assets directory at runtime. That keeps the Windows/Linux distribution to a +// single executable and avoids the common failure mode where the app starts with +// a generic icon because a sidecar PNG was not copied with the binary. +// +// The blank import enables the compiler directive below; no runtime package +// initialization from embed is required. +// //go:embed pysentry-icon.png var iconBytes []byte func Icon() fyne.Resource { + // Fyne accepts resources from memory, so the same embedded PNG can be used + // for the window icon and tray icon. The Windows Explorer icon is still added + // by the build script through the .ico resource, because Explorer reads PE + // resources rather than Fyne runtime state. return fyne.NewStaticResource("pysentry-icon.png", iconBytes) } diff --git a/cmd/pysentry/main.go b/cmd/pysentry/main.go index def4ea4..7fc550c 100644 --- a/cmd/pysentry/main.go +++ b/cmd/pysentry/main.go @@ -3,5 +3,8 @@ package main import "github.com/pysentry/pysentry/src/gui" func main() { + // The executable entry point intentionally delegates all startup work to the + // GUI package. Keeping main small makes it easier to add platform-specific + // packaging later without mixing window setup, storage, and scheduler logic. gui.Run() } diff --git a/scripts/build-linux-docker.sh b/scripts/build-linux-docker.sh index 3f5223e..43653c7 100644 --- a/scripts/build-linux-docker.sh +++ b/scripts/build-linux-docker.sh @@ -1,12 +1,21 @@ #!/usr/bin/env bash set -euo pipefail +# Optional first argument mirrors build-linux.sh. The Docker build still writes +# the final artifact into the local dist/ tree, not into the container. output="${1:-dist/linux/pysentry}" -docker build -f Dockerfile.linux -t pysentry-linux-builder . +# Dockerfile contains the native packages required by Fyne. Keeping that +# environment in Docker makes Linux builds repeatable from Windows hosts and CI. +docker build -f Dockerfile -t pysentry-linux-builder . + +# The image build produces /out/pysentry. A temporary container is used only as a +# convenient way to copy that file out; the app is not run inside the container. container_id="$(docker create pysentry-linux-builder)" mkdir -p "$(dirname "$output")" docker cp "${container_id}:/out/pysentry" "$output" docker rm "$container_id" >/dev/null +# Icons are embedded in the Go binary, so there is no assets directory to copy +# after extracting the Linux executable. echo "Built $output" diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh index b71a8df..2c6942e 100644 --- a/scripts/build-linux.sh +++ b/scripts/build-linux.sh @@ -1,13 +1,22 @@ #!/usr/bin/env bash set -euo pipefail +# Optional first argument lets a developer or CI job choose the output path. +# dist/linux/pysentry is the default so generated binaries stay outside src/. output="${1:-dist/linux/pysentry}" mkdir -p "$(dirname "$output")" +# Fyne needs CGO for its native desktop backend. The script pins the target to +# linux/amd64 because this is the first supported Linux artifact; other +# architectures can be added later as explicit build targets. export CGO_ENABLED=1 export GOOS=linux export GOARCH=amd64 +# -trimpath removes local machine paths from debug/build metadata. -s -w strips +# symbol/debug tables to keep the desktop binary smaller. go build -trimpath -ldflags "-s -w" -o "$output" ./cmd/pysentry +# The application icon is embedded by Go, so the Linux build does not need a +# sidecar assets directory beside the executable. echo "Built $output" diff --git a/scripts/build-windows.bat b/scripts/build-windows.bat index 0e31340..07f28ae 100644 --- a/scripts/build-windows.bat +++ b/scripts/build-windows.bat @@ -1,27 +1,46 @@ @echo off setlocal enabledelayedexpansion +REM Optional first argument allows CI or a developer to choose another output +REM path. The default keeps all generated binaries under dist\ so the source tree +REM stays clean and the old bin\ folder is no longer needed. set "OUTPUT=%~1" if "%OUTPUT%"=="" set "OUTPUT=dist\windows\pysentry.exe" +REM Prefer the standard Go installer path on Windows, but fall back to PATH for +REM machines where Go was installed by another package manager. set "GOEXE=%ProgramFiles%\Go\bin\go.exe" if not exist "%GOEXE%" set "GOEXE=go" +REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain +REM expected by the Windows build; prepending it keeps the script self-contained +REM without permanently changing the user's system PATH. if exist "C:\msys64\ucrt64\bin" set "PATH=C:\msys64\ucrt64\bin;%PATH%" +REM Build a 64-bit Windows binary. CGO must stay enabled for Fyne; disabling it +REM would make the native GUI backend fail to compile. set "CGO_ENABLED=1" set "GOOS=windows" set "GOARCH=amd64" +REM Create the target directory before invoking Go so custom output paths work. for %%I in ("%OUTPUT%") do set "OUTDIR=%%~dpI" if not exist "%OUTDIR%" mkdir "%OUTDIR%" +REM windres embeds the .ico file into the PE executable so Windows Explorer, +REM shortcuts, and the taskbar can show the PySentry icon. The Go embed package +REM handles Fyne's runtime icon, but Explorer reads this Windows resource instead. where windres.exe >nul 2>nul if %ERRORLEVEL%==0 ( windres.exe -O coff -o cmd\pysentry\rsrc_windows_amd64.syso packaging\windows\pysentry.rc ) +REM -trimpath removes local machine paths from the binary, -s -w reduce binary +REM size, and -H=windowsgui prevents a separate console window from opening when +REM the GUI app starts from Explorer or a shortcut. "%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui" -o "%OUTPUT%" .\cmd\pysentry if errorlevel 1 exit /b 1 +REM Icons are embedded into the executable, so no assets directory is copied next +REM to the binary. Runtime YAML and log files are created by the app itself. echo Built %OUTPUT% diff --git a/src/core/model.go b/src/core/model.go index 4778ab3..510523f 100644 --- a/src/core/model.go +++ b/src/core/model.go @@ -2,6 +2,9 @@ package core import "time" +// Config is stored in pysentry.yaml next to the program. It contains only +// application-level choices: where to read jobs from, where to write logs, and +// how the desktop shell should behave. type Config struct { JobsDir string `yaml:"jobs_dir"` LogsDir string `yaml:"logs_dir"` @@ -11,10 +14,17 @@ type Config struct { NotifyOnFailure bool `yaml:"notify_on_failure"` } +// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level +// object leaves room for future metadata without breaking the basic file format. type JobsFile struct { Jobs []Job `yaml:"jobs"` } +// Job is the user-visible scheduled command. +// +// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI +// while PySentry 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"` @@ -28,9 +38,15 @@ type Job struct { 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 + // must not persist it. nextDue time.Time } +// RunRecord represents one visible activity item. Scheduled and manual command +// output is also written to a log file; the in-memory Output copy exists so the +// latest run can be displayed without reopening the log on every repaint. type RunRecord struct { Time string `yaml:"time"` JobID int `yaml:"job_id"` diff --git a/src/core/paths.go b/src/core/paths.go index 3450010..f4c4d92 100644 --- a/src/core/paths.go +++ b/src/core/paths.go @@ -6,10 +6,18 @@ import ( ) const ( + // The config file stays beside the executable so the portable build behaves + // predictably: moving the program folder moves its settings with it. ConfigFileName = "pysentry.yaml" - JobsFileName = "jobs.yaml" + // Jobs are kept in a separate YAML file because the user can choose a + // different jobs directory, while application settings remain local to the + // installed/copied program. + JobsFileName = "jobs.yaml" ) +// Paths contains both the physical program location and the resolved runtime +// storage locations. Keeping resolved paths in one struct prevents the GUI and +// scheduler from interpreting relative directories differently. type Paths struct { AppDir string ConfigPath string @@ -19,6 +27,10 @@ type Paths struct { } func ResolvePaths() (Paths, error) { + // os.Executable is used instead of the current working directory because GUI + // apps are often launched from Explorer, a tray shortcut, or a desktop file. + // In those cases the working directory can be surprising, but the executable + // path is stable and matches the "portable app folder" storage model. executable, err := os.Executable() if err != nil { return Paths{}, err diff --git a/src/core/runner.go b/src/core/runner.go index 3e7f0be..6b528ae 100644 --- a/src/core/runner.go +++ b/src/core/runner.go @@ -19,9 +19,17 @@ const commandTimeout = 30 * time.Second func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord { started := time.Now() + // Commands can hang forever if a script waits for input or a child process + // stalls. A fixed timeout is a conservative first guardrail for a desktop + // scheduler; later it can become a per-job setting without changing the + // runner contract. 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) var stdout bytes.Buffer var stderr bytes.Buffer @@ -59,6 +67,9 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe LogFile: logFile, Output: output, } + // Keep a small in-memory history for the currently running GUI. Full command + // output is persisted to files, so retaining every past record in RAM would + // only duplicate data and make long sessions grow without bound. job.Logs = append([]RunRecord{record}, job.Logs...) if len(job.Logs) > 50 { job.Logs = job.Logs[:50] @@ -82,6 +93,9 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error { var logs []logFile cutoff := time.Now().AddDate(0, 0, -maxAgeDays) for _, entry := range entries { + // Only PySentry run logs are managed here. Directories and non-.log files + // are intentionally ignored so the user can keep notes or other artifacts + // in the same folder without the cleanup policy deleting them. if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") { continue } @@ -91,6 +105,8 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error { continue } if maxAgeDays > 0 && info.ModTime().Before(cutoff) { + // Cleanup is best-effort: failing to delete one file should not block + // the scheduler from running future jobs. _ = os.Remove(path) continue } @@ -101,6 +117,9 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error { return nil } sort.Slice(logs, func(i int, j int) bool { + // Newest files are kept first, then everything after maxFiles is removed. + // This matches the user's expectation that the most recent failures and + // command output remain available for investigation. return logs[i].modTime.After(logs[j].modTime) }) for _, old := range logs[maxFiles:] { @@ -116,6 +135,9 @@ func writeRunLog(logsDir string, job Job, trigger string, state string, detail s if err := os.MkdirAll(logsDir, 0o755); err != nil { return "" } + // The timestamp comes first so a plain directory listing is naturally sorted + // by run time. The job name is included for human scanning, but sanitized to + // 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", @@ -151,8 +173,12 @@ func sanitizeFileName(name string) string { 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) } + // 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) } @@ -160,6 +186,8 @@ func formatOutput(stdout string, stderr string) string { stdout = strings.TrimSpace(stdout) stderr = strings.TrimSpace(stderr) if stdout == "" { + // Showing an explicit placeholder is clearer than an empty panel in the + // GUI: the user can tell that the command ran but produced no stream data. stdout = "" } if stderr == "" { diff --git a/src/core/scheduler.go b/src/core/scheduler.go index 5f59bc9..f7c15ca 100644 --- a/src/core/scheduler.go +++ b/src/core/scheduler.go @@ -11,6 +11,10 @@ import ( var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) +// Scheduler owns the timing loop for jobs that are currently loaded in the GUI. +// It receives a pointer to the jobs slice because the GUI edits the same slice; +// this keeps the early architecture simple while storage and scheduling are +// still in one desktop process. type Scheduler struct { store *Store jobs *[]Job @@ -36,6 +40,10 @@ func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Schedule } func (s *Scheduler) Start() { + // A one-second ticker is accurate enough for cron-style desktop automation + // and avoids the complexity of maintaining one timer per job. Five-field cron + // expressions have minute precision, while @every values may be shorter for + // testing and lightweight local tasks. ticker := time.NewTicker(time.Second) go func() { defer ticker.Stop() @@ -60,6 +68,8 @@ func (s *Scheduler) SetPaused(paused bool) { s.paused = paused now := time.Now() + // Pause state is reflected into each job's display string so the list view is + // understandable even before the next scheduler tick. for index := range *s.jobs { job := &(*s.jobs)[index] if !job.Enabled { @@ -83,6 +93,9 @@ func (s *Scheduler) RunNow(index int) RunRecord { return RunRecord{} } job := &(*s.jobs)[index] + // Manual runs share the same runner and log writer as scheduled runs. The + // Trigger field is the only difference, which keeps History comparable and + // prevents "Run now" from becoming a separate behavior path. record := RunJob(s.ctx, job, "Manual", s.store.Paths.LogsDir) s.prepareNextRun(job, time.Now()) _ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) @@ -120,6 +133,10 @@ func (s *Scheduler) tick(now time.Time) { if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) { continue } + // Run only one due job per tick for now. That avoids overlapping shell + // commands in the GUI process and keeps the first version predictable; + // a future worker pool can add concurrency once cancellation and status + // reporting are more explicit. record = RunJob(s.ctx, job, "Schedule", s.store.Paths.LogsDir) s.prepareNextRun(job, time.Now()) _ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) @@ -166,12 +183,17 @@ func nextRunTime(schedule string, from time.Time) (time.Time, bool) { return time.Time{}, false } if strings.HasPrefix(schedule, "@every ") { + // @every is kept alongside cron because it is convenient for quick tests + // and for simple intervals that are awkward to express as five fields. interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every "))) if err != nil || interval <= 0 { return time.Time{}, false } return from.Add(interval), true } + // Standard five-field cron keeps PySentry compatible with the mental model + // users already know from Unix cron, while robfig/cron handles edge cases + // such as ranges, steps, and day-of-week names. parsed, err := cronParser.Parse(schedule) if err != nil { return time.Time{}, false diff --git a/src/core/store.go b/src/core/store.go index 064e1e8..7bf234b 100644 --- a/src/core/store.go +++ b/src/core/store.go @@ -28,6 +28,9 @@ func OpenStore() (*Store, []Job, error) { } store.Config = config store.applyConfigPaths() + // Save the config after loading so missing defaults are written back. This + // rewrites old or hand-edited files into the current clean schema without + // forcing the user to delete them manually. if err := store.SaveConfig(); err != nil { return nil, nil, err } @@ -37,6 +40,9 @@ func OpenStore() (*Store, []Job, error) { return nil, nil, err } normalizeJobs(jobs) + // Jobs are also rewritten after normalization. That keeps jobs.yaml compact: + // only durable job definitions remain, because runtime fields are tagged + // yaml:"-" in the model. if err := store.SaveJobs(jobs); err != nil { return nil, nil, err } @@ -59,6 +65,8 @@ func (s *Store) SaveJobs(jobs []Job) error { } func loadOrCreateConfig(paths Paths) (Config, error) { + // Defaults favor a portable installation: settings and jobs begin next to the + // executable, while logs are grouped under a dedicated subdirectory. config := Config{ JobsDir: ".", LogsDir: "logs", @@ -80,6 +88,8 @@ func loadOrCreateConfig(paths Paths) (Config, error) { return Config{}, err } if strings.TrimSpace(config.JobsDir) == "" { + // Empty paths are treated as missing values rather than intentional root + // directories. This avoids accidentally writing jobs to unexpected places. config.JobsDir = "." } if strings.TrimSpace(config.LogsDir) == "" { @@ -96,6 +106,8 @@ func loadOrCreateConfig(paths Paths) (Config, error) { func loadOrCreateJobs(path string) ([]Job, error) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + // The first run creates harmless sample jobs so a new user can immediately + // see scheduled and manual execution without inventing a command. jobs := defaultJobs() normalizeJobs(jobs) return jobs, writeYAML(path, JobsFile{Jobs: jobs}) @@ -117,6 +129,8 @@ func normalizeJobs(jobs []Job) { for index := range jobs { job := &jobs[index] if job.ID <= 0 { + // IDs are assigned only when absent. Existing IDs stay stable because + // History and future log associations use them to identify jobs. job.ID = next } if job.ID >= next { @@ -129,6 +143,8 @@ func normalizeJobs(jobs []Job) { job.Schedule = "@every 1m" } if strings.TrimSpace(job.Command) == "" { + // An empty command would fail in a confusing way. A safe echo command + // gives the user something observable and harmless instead. job.Command = echoCommand("PySentry job ran") } if job.LastRun == "" { @@ -144,6 +160,9 @@ func normalizeJobs(jobs []Job) { job.LastState = "Paused" job.NextRun = "Paused" } + // Runtime fields are reconstructed each time the app starts. Persisted run + // records live in log files, not in jobs.yaml, to keep the jobs file easy + // to review and edit by hand. job.Logs = nil } } @@ -156,6 +175,9 @@ func resolveConfiguredDir(appDir string, dir string) string { if filepath.IsAbs(dir) { return dir } + // Relative paths are resolved against the executable directory, not the + // process working directory. This matches ResolvePaths and keeps shortcuts, + // Explorer launches, and terminal launches consistent. return filepath.Clean(filepath.Join(appDir, dir)) } @@ -173,6 +195,9 @@ func writeYAML(path string, value any) error { if err != nil { return err } + // WriteFile replaces the full file instead of patching it in place. For small + // YAML files this is simpler and prevents stale keys from older versions from + // lingering after the schema changes. return os.WriteFile(path, data, 0o644) } @@ -208,5 +233,7 @@ func echoCommand(message string) string { if runtime.GOOS == "windows" { return "echo " + message } + // POSIX shells need quotes for messages with spaces. Single quotes inside the + // message are escaped using the standard close-quote/backslash/reopen pattern. return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'" } diff --git a/src/gui/app.go b/src/gui/app.go index b4f472f..758e280 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -23,10 +23,15 @@ const appID = "io.github.pysentry.desktop" const allFolders = "All" const noFolder = "No folder" +// The GUI package aliases core types to keep widget callbacks short. The actual +// durable model still lives in src/core, so GUI code does not define a second +// copy of the scheduler data. type job = core.Job type event = core.RunRecord func Run() { + // A stable app ID lets Fyne persist desktop preferences consistently across + // launches and gives tray/window integration a predictable identity. a := app.NewWithID(appID) a.SetIcon(loadAppIcon()) @@ -44,6 +49,8 @@ func loadAppIcon() fyne.Resource { func configureSystemTray(a fyne.App, w fyne.Window) { desk, ok := a.(desktop.App) if !ok { + // Not every Fyne driver exposes desktop tray features. Returning silently + // keeps the same binary usable on platforms or sessions without a tray. return } @@ -59,6 +66,9 @@ func configureSystemTray(a fyne.App, w fyne.Window) { ) desk.SetSystemTrayMenu(menu) w.SetCloseIntercept(func() { + // Closing hides the window instead of quitting because scheduler tools are + // expected to keep working in the background. The explicit Quit tray item + // remains the way to stop the process. w.Hide() }) } @@ -70,6 +80,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject { } events := collectActivity(jobs) + // The GUI keeps the loaded jobs slice in memory and persists changes after + // each edit/run. This keeps the first version responsive and easy to reason + // about; a database would be unnecessary overhead for one YAML file. nextJobID := nextID(jobs) selected := 0 selectedFolder := allFolders @@ -86,6 +99,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject { commandOutput := widget.NewTextGrid() commandOutput.SetText(jobs[selected].Output) commandOutputScroll := container.NewScroll(commandOutput) + // Command output can contain long lines and preserved whitespace. TextGrid is + // used instead of Label so stdout/stderr remains readable and does not vanish + // against the theme when it is placed inside a scroll container. commandOutputScroll.SetMinSize(fyne.NewSize(520, 160)) history := newHistoryView(&events) jobLogs := widget.NewList( @@ -103,6 +119,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject { updateDetails := func(index int) { if index < 0 || index >= len(jobs) { + // A folder filter can temporarily leave no selectable rows. Clearing + // the details panel avoids showing stale information for a hidden job. title.SetText("No job selected") folder.SetText("") schedule.SetText("") @@ -125,6 +143,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject { commandOutput.SetText(current.Output) } refresh := func() { + // Several callbacks mutate jobs, filters, and event history. A single + // refresh closure keeps the different widgets synchronized after each + // mutation without introducing a heavier state-management layer. filteredJobs = filteredJobIndexes(jobs, selectedFolder) updateDetails(selected) jobLogs.Refresh() @@ -148,6 +169,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject { current := jobs[filteredJobs[id]] 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) status.SetText(statusText(current)) }, @@ -169,6 +192,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject { filteredJobs = filteredJobIndexes(jobs, selectedFolder) list.Refresh() if len(filteredJobs) == 0 { + // The "No folder" filter is intentionally allowed to be empty. It is a + // real filter choice, not an error state, so the selection is cleared. selected = -1 updateDetails(-1) return @@ -186,6 +211,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject { jobs = append(jobs, saved) selected = len(jobs) - 1 created := newEvent(saved.ID, saved.Name, "Created", "Job was added") + // UI events are kept in memory for the current session. They explain + // user actions in History, while command output remains in log files. jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...) events = append([]event{created}, events...) _ = store.SaveJobs(jobs) @@ -229,6 +256,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject { return } if schedulerPaused { + // The global pause is treated as an emergency stop for all execution, + // including manual "Run now", so the user has one reliable switch. dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w) return } @@ -262,6 +291,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject { stopAllButton.SetIcon(theme.MediaStopIcon()) for index := range jobs { if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" { + // The scheduler will calculate the exact next run when it is + // resumed; this interim text prevents a stale paused timestamp. jobs[index].NextRun = "Waiting for scheduler" } } @@ -307,6 +338,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject { return } deleted := jobs[selected] + // Deletion is confirmed because jobs can represent real system actions. + // There is no undo yet, so accidental removal should require one more click. dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) { if !confirm { return @@ -358,6 +391,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject { ) scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) { + // Scheduled runs happen on the scheduler goroutine. The callback updates + // the shared in-memory event list so History reflects background activity. events = append([]event{record}, events...) refresh() }) @@ -381,6 +416,8 @@ func statusText(j job) string { } func newEvent(jobID int, jobName string, state string, detail string) event { + // UI events use a short time because they are session-local activity markers. + // Command runs use full timestamps from core.RunJob and have log files. return event{ Time: time.Now().Format("15:04:05"), JobID: jobID, @@ -405,6 +442,9 @@ func eventText(e event) string { func collectActivity(jobs []job) []event { var events []event for _, current := range jobs { + // At startup this is usually empty because jobs.yaml does not persist + // runtime logs. The function still centralizes the merge for future + // history loading from log metadata. events = append(events, current.Logs...) } return events @@ -437,6 +477,8 @@ func filteredJobIndexes(jobs []job, folder string) []int { } func folderOptions(jobs []job) []string { + // "All" and "No folder" are always present so the filter UI is stable even + // before the user creates folders. options := []string{allFolders, noFolder} seen := map[string]bool{allFolders: true, noFolder: true} for _, current := range jobs { @@ -505,6 +547,8 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { return } if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(schedule.Text) == "" || strings.TrimSpace(command.Text) == "" { + // These three fields are the minimum executable job definition. + // Folder is optional because ungrouped jobs are a supported workflow. dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w) return } @@ -594,10 +638,15 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje settingsStatus.SetText("Save failed: " + err.Error()) return } + // When the jobs directory changes, save the currently loaded jobs to the + // newly resolved path immediately. That makes the setting visible on disk + // without requiring a restart or a separate migration command. if err := store.SaveJobs(*jobs); err != nil { settingsStatus.SetText("Jobs save failed: " + err.Error()) return } + // Cleanup runs on settings save so a user who tightens retention limits + // sees the new policy take effect right away. if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil { settingsStatus.SetText("Saved, cleanup failed: " + err.Error()) return @@ -631,6 +680,8 @@ func chooseFolder(w fyne.Window, target *widget.Entry) { } target.SetText(uri.Path()) }, w) + // The default folder picker can be cramped on Windows. A larger size makes + // long paths readable and avoids forcing the user to resize it every time. folderDialog.Resize(fyne.NewSize(900, 640)) folderDialog.Show() }