Document design choices and standardize Dockerfile

This commit is contained in:
mixeme
2026-06-15 00:05:12 +03:00
parent 59718e21b4
commit 47e2ba7272
14 changed files with 292 additions and 24 deletions
+31
View File
@@ -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
-22
View File
@@ -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
+51
View File
@@ -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_<sanitized job name>.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
+12
View File
@@ -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)
}
+3
View File
@@ -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()
}
+10 -1
View File
@@ -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"
+9
View File
@@ -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"
+19
View File
@@ -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%
+16
View File
@@ -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"`
+12
View File
@@ -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"
// 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
+28
View File
@@ -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 = "<empty>"
}
if stderr == "" {
+22
View File
@@ -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
+27
View File
@@ -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, "'", "'\\''") + "'"
}
+51
View File
@@ -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()
}