From 5727e13f233ba690f8cf2b9f702747fb625e227c Mon Sep 17 00:00:00 2001 From: mixeme Date: Mon, 15 Jun 2026 07:35:52 +0300 Subject: [PATCH] Add autostart status and release builds --- Dockerfile | 37 ++++++++++---- README.md | 87 ++++++++++++++++++++++++++++----- scripts/build-linux-docker.sh | 24 ++++++--- scripts/build-linux.sh | 10 ++-- scripts/build-release-linux.sh | 22 +++++++++ scripts/build-windows.bat | 13 ++++- src/core/autostart_linux.go | 89 ++++++++++++++++++++++++++++++++++ src/core/autostart_other.go | 19 ++++++++ src/core/autostart_windows.go | 49 +++++++++++++++++++ src/core/model.go | 1 + src/core/paths.go | 20 ++++---- src/core/runner.go | 1 + src/core/runner_other.go | 11 +++++ src/core/runner_windows.go | 16 ++++++ src/core/scheduler.go | 67 ++++++++++++++++++------- src/core/store.go | 3 +- src/core/version.go | 6 +++ src/gui/app.go | 40 +++++++++++---- 18 files changed, 443 insertions(+), 72 deletions(-) create mode 100644 scripts/build-release-linux.sh create mode 100644 src/core/autostart_linux.go create mode 100644 src/core/autostart_other.go create mode 100644 src/core/autostart_windows.go create mode 100644 src/core/runner_other.go create mode 100644 src/core/runner_windows.go create mode 100644 src/core/version.go diff --git a/Dockerfile b/Dockerfile index 0d9c903..d291235 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,19 @@ FROM golang:1.22-bookworm # 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 && \ + dpkg --add-architecture arm64 && \ + apt-get update && \ apt-get install -y --no-install-recommends \ ca-certificates \ gcc \ + gcc-aarch64-linux-gnu \ + gcc-mingw-w64-x86-64 \ + binutils-mingw-w64-x86-64 \ + pkg-config \ libgl1-mesa-dev \ - xorg-dev && \ + xorg-dev \ + libgl1-mesa-dev:arm64 \ + xorg-dev:arm64 && \ rm -rf /var/lib/apt/lists/* WORKDIR /src @@ -20,12 +28,21 @@ 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 +# CGO is required by Fyne. This builder produces the release artifacts from +# Linux: Linux amd64, Linux arm64, and a Windows amd64 binary cross-compiled with +# MinGW. The Windows resource is generated inside the container so Explorer still +# sees the application icon. +RUN version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" && \ + version="${version:-0.0.0-dev}" && \ + mkdir -p /out/linux /out/windows && \ + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${version}" \ + -o "/out/linux/pysentry-${version}-linux-amd64" ./cmd/pysentry && \ + CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \ + PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig \ + go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${version}" \ + -o "/out/linux/pysentry-${version}-linux-arm64" ./cmd/pysentry && \ + x86_64-w64-mingw32-windres -O coff -o cmd/pysentry/rsrc_windows_amd64.syso packaging/windows/pysentry.rc && \ + CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w -H=windowsgui -X github.com/pysentry/pysentry/src/core.Version=${version}" \ + -o "/out/windows/pysentry-${version}-windows-amd64.exe" ./cmd/pysentry diff --git a/README.md b/README.md index 84d8dd2..89abba5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ PySentry is being designed and implemented with assistance from OpenAI Codex. - Log cleanup by maximum file count and maximum age. - Global pause/resume for all job execution. - Windows tray support. +- Version shown in the window title, Settings, and build artifact names. ## Requirements @@ -44,9 +45,11 @@ 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. +# Builds dist\windows\pysentry--windows-amd64.exe. The script changes +# to the repository root first, so double-clicking it from Explorer works. It +# also 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 ``` @@ -56,7 +59,7 @@ The binary is written to: ```text # GUI executable produced by scripts\build-windows.bat. -dist\windows\pysentry.exe +dist\windows\pysentry-0.1.0-windows-amd64.exe ``` Linux: @@ -71,14 +74,15 @@ The binary is written to: ```text # Linux executable produced by scripts/build-linux.sh. -dist/linux/pysentry +dist/linux/pysentry-0.1.0-linux-amd64 ``` 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. +# Builds the Linux binary inside Docker using the image tag +# gitea.mixdep.ru/mix/pysentry-builder. Useful from hosts or CI jobs where the +# native Linux/Fyne packages are not installed locally. chmod +x ./scripts/build-linux-docker.sh ./scripts/build-linux-docker.sh ``` @@ -87,7 +91,30 @@ The binary is copied to: ```text # Linux executable copied out of the Docker build image. -dist\linux\pysentry +dist\linux\pysentry-0.1.0-linux-amd64 +``` + +Release build from Linux: + +```bash +# Builds Linux amd64, Linux arm64, and Windows amd64 artifacts from one +# Linux/Docker workflow. The Dockerfile includes Linux Fyne dependencies plus +# cross-compilers for arm64 Linux and the Windows .exe. +chmod +x ./scripts/build-release-linux.sh +./scripts/build-release-linux.sh +``` + +The binaries are copied to: + +```text +# Linux artifact. +dist/linux/pysentry-0.1.0-linux-amd64 + +# Linux arm64 artifact. +dist/linux/pysentry-0.1.0-linux-arm64 + +# Windows artifact cross-compiled from Linux. +dist/windows/pysentry-0.1.0-windows-amd64.exe ``` ## Run From Source @@ -120,8 +147,8 @@ 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. +# Directory containing jobs.yaml. "." means "the folder where the PySentry +# executable 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 @@ -134,6 +161,9 @@ max_log_files: 100 # Delete .log files older than this many days during cleanup. max_log_age_days: 30 +# Start PySentry automatically when the current desktop user signs in. +start_on_login: false + # Closing the window hides it to the tray instead of stopping the scheduler. keep_running_in_tray: true @@ -159,7 +189,7 @@ jobs: folder: Examples # Either @every with a Go duration, or a standard five-field cron expression. - schedule: '@every 10s' + schedule: '@every 1m' # Command passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux. command: echo PySentry test job: scheduler is alive @@ -208,6 +238,41 @@ Standard 5-field cron schedules: Changing `jobs_dir` saves the current job list to the new directory. +The `Start on login` setting shows an `OK` or `Problem` status next to the checkbox. Saving settings with the checkbox enabled rewrites the autostart entry using the current executable path. + +## Autostart + +PySentry is a user desktop application, not a system daemon, so autostart should be configured per user. + +Linux: + +```ini +# PySentry writes a systemd user unit and enables it with +# systemctl --user enable --now pysentry.service when Start on login is enabled. +# A user unit starts after login and can run the tray/GUI app in the user's +# desktop session. +[Unit] +Description=PySentry desktop scheduler + +[Service] +ExecStart=/opt/pysentry/pysentry-0.1.0-linux-amd64 +Restart=on-failure + +[Install] +WantedBy=default.target +``` + +Windows: + +```text +# PySentry writes an HKCU Run entry when Start on login is enabled. It needs no +# administrator rights and starts PySentry when the current user signs in. Task +# Scheduler remains a later option if delayed start or elevated tasks become +# necessary. Saving settings with the checkbox enabled rewrites this entry, so it +# repairs an old path after the executable was moved or renamed. +HKCU\Software\Microsoft\Windows\CurrentVersion\Run\PySentry +``` + ## Project Layout - `cmd/pysentry` starts the desktop app. diff --git a/scripts/build-linux-docker.sh b/scripts/build-linux-docker.sh index 43653c7..3aab429 100644 --- a/scripts/build-linux-docker.sh +++ b/scripts/build-linux-docker.sh @@ -2,19 +2,27 @@ 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}" +# the final artifact into the local dist/ tree, not into the container. The +# default includes the application version and target platform. +version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" +version="${version:-0.0.0-dev}" +output="${1:-dist/linux/pysentry-${version}-linux-amd64}" # 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 . +docker build -f Dockerfile -t gitea.mixdep.ru/mix/pysentry-builder . + +# The image build produces /out/linux and /out/windows. This helper copies only +# the Linux binary for compatibility with the older Linux-only workflow; use +# build-release-linux.sh when both platform artifacts are needed. +container_id="$(docker create gitea.mixdep.ru/mix/pysentry-builder)" +cleanup() { + docker rm "$container_id" >/dev/null +} +trap cleanup EXIT -# 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 +docker cp "${container_id}:/out/linux/pysentry-${version}-linux-amd64" "$output" # Icons are embedded in the Go binary, so there is no assets directory to copy # after extracting the Linux executable. diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh index 2c6942e..5a6394d 100644 --- a/scripts/build-linux.sh +++ b/scripts/build-linux.sh @@ -1,9 +1,11 @@ #!/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}" +# Optional first argument lets a developer or CI job choose the output path. The +# default includes the application version and target platform. +version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" +version="${version:-0.0.0-dev}" +output="${1:-dist/linux/pysentry-${version}-linux-amd64}" mkdir -p "$(dirname "$output")" # Fyne needs CGO for its native desktop backend. The script pins the target to @@ -15,7 +17,7 @@ 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 +go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${version}" -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. diff --git a/scripts/build-release-linux.sh b/scripts/build-release-linux.sh new file mode 100644 index 0000000..63fb9a2 --- /dev/null +++ b/scripts/build-release-linux.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build all release artifacts from a Linux host or CI runner. The Docker image +# contains Linux/Fyne dependencies for amd64 and arm64, plus the MinGW +# cross-compiler used for the Windows GUI executable. +tag="gitea.mixdep.ru/mix/pysentry-builder" + +docker build -f Dockerfile -t "$tag" . + +container_id="$(docker create "$tag")" +cleanup() { + docker rm "$container_id" >/dev/null +} +trap cleanup EXIT + +mkdir -p dist/linux dist/windows +docker cp "${container_id}:/out/linux/." dist/linux +docker cp "${container_id}:/out/windows/." dist/windows + +echo "Built release artifacts:" +find dist/linux dist/windows -maxdepth 1 -type f -print diff --git a/scripts/build-windows.bat b/scripts/build-windows.bat index 07f28ae..29c8d7b 100644 --- a/scripts/build-windows.bat +++ b/scripts/build-windows.bat @@ -1,11 +1,20 @@ @echo off setlocal enabledelayedexpansion +REM Double-clicking a .bat file can start it with an arbitrary working +REM directory. Move to the repository root (the parent of scripts\) before using +REM relative paths such as .\cmd\pysentry and packaging\windows\pysentry.rc. +cd /d "%~dp0\.." + +for /f "tokens=4" %%V in ('findstr /C:"var Version" src\core\version.go') do set "VERSION=%%~V" +if "%VERSION%"=="" set "VERSION=0.0.0-dev" +set "VERSION=%VERSION:"=%" + 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" +if "%OUTPUT%"=="" set "OUTPUT=dist\windows\pysentry-%VERSION%-windows-amd64.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. @@ -38,7 +47,7 @@ if %ERRORLEVEL%==0 ( 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 +"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X github.com/pysentry/pysentry/src/core.Version=%VERSION%" -o "%OUTPUT%" .\cmd\pysentry if errorlevel 1 exit /b 1 REM Icons are embedded into the executable, so no assets directory is copied next diff --git a/src/core/autostart_linux.go b/src/core/autostart_linux.go new file mode 100644 index 0000000..3870353 --- /dev/null +++ b/src/core/autostart_linux.go @@ -0,0 +1,89 @@ +//go:build linux + +package core + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const autostartUnitName = "pysentry.service" + +func SetAutostart(enabled bool, executablePath string) error { + unitDir, err := userSystemdDir() + if err != nil { + return err + } + unitPath := filepath.Join(unitDir, autostartUnitName) + + if enabled { + if err := os.MkdirAll(unitDir, 0o755); err != nil { + return err + } + unit := fmt.Sprintf(`[Unit] +Description=PySentry desktop scheduler + +[Service] +ExecStart=%s +Restart=on-failure + +[Install] +WantedBy=default.target +`, executablePath) + if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { + return err + } + if err := exec.Command("systemctl", "--user", "daemon-reload").Run(); err != nil { + return err + } + return exec.Command("systemctl", "--user", "enable", "--now", autostartUnitName).Run() + } + + _ = exec.Command("systemctl", "--user", "disable", "--now", autostartUnitName).Run() + if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) { + return err + } + return exec.Command("systemctl", "--user", "daemon-reload").Run() +} + +func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) { + unitDir, err := userSystemdDir() + if err != nil { + return false, "Cannot resolve user systemd directory" + } + unitPath := filepath.Join(unitDir, autostartUnitName) + data, readErr := os.ReadFile(unitPath) + enabledErr := exec.Command("systemctl", "--user", "is-enabled", "--quiet", autostartUnitName).Run() + + if !expectedEnabled { + if os.IsNotExist(readErr) && enabledErr != nil { + return true, "Autostart is off" + } + return false, "Autostart unit exists while setting is off" + } + if readErr != nil { + return false, "Autostart unit is missing" + } + if !strings.Contains(string(data), executablePath) { + return false, "Autostart unit points to another executable" + } + if enabledErr != nil { + return false, "Autostart unit is not enabled" + } + return true, "Autostart is configured" +} + +func userSystemdDir() (string, error) { + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + configHome = filepath.Join(home, ".config") + } + return filepath.Join(configHome, "systemd", "user"), nil +} diff --git a/src/core/autostart_other.go b/src/core/autostart_other.go new file mode 100644 index 0000000..5744b91 --- /dev/null +++ b/src/core/autostart_other.go @@ -0,0 +1,19 @@ +//go:build !windows && !linux + +package core + +import "fmt" + +func SetAutostart(enabled bool, executablePath string) error { + if !enabled { + return nil + } + return fmt.Errorf("autostart is not implemented for this platform") +} + +func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) { + if !expectedEnabled { + return true, "Autostart is off" + } + return false, "Autostart is not implemented for this platform" +} diff --git a/src/core/autostart_windows.go b/src/core/autostart_windows.go new file mode 100644 index 0000000..cf265c9 --- /dev/null +++ b/src/core/autostart_windows.go @@ -0,0 +1,49 @@ +package core + +import ( + "fmt" + "os/exec" + "strings" +) + +const autostartName = "PySentry" + +func SetAutostart(enabled bool, executablePath string) error { + if enabled { + // Remove any stale entry first. This makes "uncheck, save, check, save" + // and even a plain "check, save" repair an old path after the executable + // was moved or renamed for a new version. + deleteCommand := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f") + configureHiddenWindow(deleteCommand) + _ = deleteCommand.Run() + + command := exec.Command("reg.exe", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/t", "REG_SZ", "/d", fmt.Sprintf("%q", executablePath), "/f") + configureHiddenWindow(command) + return command.Run() + } + command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f") + configureHiddenWindow(command) + _ = command.Run() + return nil +} + +func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) { + command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName) + configureHiddenWindow(command) + output, err := command.Output() + if !expectedEnabled { + if err != nil { + return true, "Autostart is off" + } + return false, "Autostart entry exists while setting is off" + } + if err != nil { + return false, "Autostart entry is missing" + } + + text := strings.ReplaceAll(string(output), `"`, "") + if !strings.Contains(text, executablePath) { + return false, "Autostart points to another executable" + } + return true, "Autostart is configured" +} diff --git a/src/core/model.go b/src/core/model.go index 510523f..c92ec34 100644 --- a/src/core/model.go +++ b/src/core/model.go @@ -10,6 +10,7 @@ type Config struct { LogsDir string `yaml:"logs_dir"` MaxLogFiles int `yaml:"max_log_files"` MaxLogAgeDays int `yaml:"max_log_age_days"` + StartOnLogin bool `yaml:"start_on_login"` KeepRunningInTray bool `yaml:"keep_running_in_tray"` NotifyOnFailure bool `yaml:"notify_on_failure"` } diff --git a/src/core/paths.go b/src/core/paths.go index f4c4d92..f5592ff 100644 --- a/src/core/paths.go +++ b/src/core/paths.go @@ -19,11 +19,12 @@ const ( // 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 - JobsDir string - JobsPath string - LogsDir string + ExecutablePath string + AppDir string + ConfigPath string + JobsDir string + JobsPath string + LogsDir string } func ResolvePaths() (Paths, error) { @@ -39,9 +40,10 @@ func ResolvePaths() (Paths, error) { appDir := filepath.Dir(executable) configPath := filepath.Join(appDir, ConfigFileName) return Paths{ - AppDir: appDir, - ConfigPath: configPath, - JobsDir: appDir, - JobsPath: filepath.Join(appDir, JobsFileName), + ExecutablePath: executable, + AppDir: appDir, + ConfigPath: configPath, + JobsDir: appDir, + JobsPath: filepath.Join(appDir, JobsFileName), }, nil } diff --git a/src/core/runner.go b/src/core/runner.go index 6b528ae..b26d329 100644 --- a/src/core/runner.go +++ b/src/core/runner.go @@ -31,6 +31,7 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe // than argv-based execution, but it is the expected behavior for a cron-like // tool that supports redirection, environment expansion, and shell builtins. command := shellCommand(runCtx, job.Command) + configureHiddenWindow(command) var stdout bytes.Buffer var stderr bytes.Buffer command.Stdout = &stdout diff --git a/src/core/runner_other.go b/src/core/runner_other.go new file mode 100644 index 0000000..e57d7c4 --- /dev/null +++ b/src/core/runner_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package core + +import "os/exec" + +func configureHiddenWindow(command *exec.Cmd) { + // Non-Windows platforms do not create a new console window for sh -c from a + // desktop process in the same way Windows does, so no extra process attribute + // is required here. +} diff --git a/src/core/runner_windows.go b/src/core/runner_windows.go new file mode 100644 index 0000000..38f0efd --- /dev/null +++ b/src/core/runner_windows.go @@ -0,0 +1,16 @@ +package core + +import ( + "os/exec" + "syscall" +) + +func configureHiddenWindow(command *exec.Cmd) { + // PySentry is a GUI scheduler, so child commands should not flash a console + // window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools + // quiet while stdout/stderr are still captured through pipes. + command.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: 0x08000000, + HideWindow: true, + } +} diff --git a/src/core/scheduler.go b/src/core/scheduler.go index f7c15ca..9f547c3 100644 --- a/src/core/scheduler.go +++ b/src/core/scheduler.go @@ -85,22 +85,17 @@ func (s *Scheduler) SetPaused(paused bool) { _ = s.store.SaveJobs(*s.jobs) } -func (s *Scheduler) RunNow(index int) RunRecord { +func (s *Scheduler) RunNow(index int) bool { s.mu.Lock() defer s.mu.Unlock() if index < 0 || index >= len(*s.jobs) { - return RunRecord{} + return false } - 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) - _ = s.store.SaveJobs(*s.jobs) - return record + return s.startRunLocked(index, "Manual") } func (s *Scheduler) RefreshSchedule(index int) { @@ -123,7 +118,6 @@ func (s *Scheduler) RefreshSchedule(index int) { } func (s *Scheduler) tick(now time.Time) { - var record RunRecord var changed bool s.mu.Lock() @@ -137,21 +131,58 @@ func (s *Scheduler) tick(now time.Time) { // 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) - changed = true + changed = s.startRunLocked(index, "Schedule") break } } - if changed { - _ = s.store.SaveJobs(*s.jobs) - } s.mu.Unlock() + _ = changed +} - if changed && s.onChange != nil { - s.onChange(record) +func (s *Scheduler) startRunLocked(index int, trigger string) bool { + job := &(*s.jobs)[index] + if job.LastState == "Running" { + return false } + + jobCopy := *job + job.LastState = "Running" + job.NextRun = "Running" + job.nextDue = time.Time{} + _ = s.store.SaveJobs(*s.jobs) + + go func() { + record := RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir) + + s.mu.Lock() + if current := s.findJobByIDLocked(jobCopy.ID); current != nil { + current.LastRun = record.Time + current.LastState = record.State + current.Output = record.Output + current.Logs = append([]RunRecord{record}, current.Logs...) + if len(current.Logs) > 50 { + current.Logs = current.Logs[:50] + } + s.prepareNextRun(current, time.Now()) + _ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) + _ = s.store.SaveJobs(*s.jobs) + } + s.mu.Unlock() + + if s.onChange != nil { + s.onChange(record) + } + }() + return true +} + +func (s *Scheduler) findJobByIDLocked(id int) *Job { + for index := range *s.jobs { + if (*s.jobs)[index].ID == id { + return &(*s.jobs)[index] + } + } + return nil } func (s *Scheduler) resetNextRuns(now time.Time) { diff --git a/src/core/store.go b/src/core/store.go index 7bf234b..81e0723 100644 --- a/src/core/store.go +++ b/src/core/store.go @@ -72,6 +72,7 @@ func loadOrCreateConfig(paths Paths) (Config, error) { LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, + StartOnLogin: false, KeepRunningInTray: true, NotifyOnFailure: true, } @@ -207,7 +208,7 @@ func defaultJobs() []Job { ID: 1, Name: "Hello scheduler", Folder: "Examples", - Schedule: "@every 10s", + Schedule: "@every 1m", Command: echoCommand("PySentry test job: scheduler is alive"), Enabled: true, }, diff --git a/src/core/version.go b/src/core/version.go new file mode 100644 index 0000000..02445bc --- /dev/null +++ b/src/core/version.go @@ -0,0 +1,6 @@ +package core + +// Version is the application version shown in the GUI and used by build +// scripts in artifact names. It is a var rather than a const so release builds +// can override it with Go ldflags when CI tags a build. +var Version = "0.1.0" diff --git a/src/gui/app.go b/src/gui/app.go index 758e280..a4149f8 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -35,7 +35,7 @@ func Run() { a := app.NewWithID(appID) a.SetIcon(loadAppIcon()) - w := a.NewWindow("PySentry") + w := a.NewWindow("PySentry " + core.Version) configureSystemTray(a, w) w.Resize(fyne.NewSize(1120, 720)) w.SetContent(newMainView(w)) @@ -104,16 +104,14 @@ func newMainView(w fyne.Window) fyne.CanvasObject { // against the theme when it is placed inside a scroll container. commandOutputScroll.SetMinSize(fyne.NewSize(520, 160)) history := newHistoryView(&events) + selectedLogs := append([]event(nil), jobs[selected].Logs...) jobLogs := widget.NewList( func() int { - if selected < 0 || selected >= len(jobs) { - return 0 - } - return len(jobs[selected].Logs) + return len(selectedLogs) }, func() fyne.CanvasObject { return widget.NewLabel("log") }, func(id widget.ListItemID, item fyne.CanvasObject) { - item.(*widget.Label).SetText(eventText(jobs[selected].Logs[id])) + item.(*widget.Label).SetText(eventText(selectedLogs[id])) }, ) @@ -129,6 +127,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject { nextRun.SetText("") state.SetText("") commandOutput.SetText("") + selectedLogs = nil return } selected = index @@ -141,6 +140,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject { nextRun.SetText(current.NextRun) state.SetText(current.LastState) commandOutput.SetText(current.Output) + selectedLogs = append(selectedLogs[:0], current.Logs...) } refresh := func() { // Several callbacks mutate jobs, filters, and event history. A single @@ -261,11 +261,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject { dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w) return } - ran := scheduler.RunNow(selected) - if ran.Time == "" { + if !scheduler.RunNow(selected) { return } - events = append([]event{ran}, events...) list.Refresh() refresh() }) @@ -589,6 +587,21 @@ func newHistoryView(events *[]event) *fyne.Container { } func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject { + startOnLogin := widget.NewCheck("Start PySentry when I sign in", nil) + startOnLogin.SetChecked(store.Config.StartOnLogin) + autostartStatus := widget.NewLabel("") + refreshAutostartStatus := func() { + ok, message := core.AutostartStatus(startOnLogin.Checked, store.Paths.ExecutablePath) + if ok { + autostartStatus.SetText("OK: " + message) + return + } + autostartStatus.SetText("Problem: " + message) + } + startOnLogin.OnChanged = func(bool) { + refreshAutostartStatus() + } + refreshAutostartStatus() minimizeToTray := widget.NewCheck("Keep running in the system tray", nil) minimizeToTray.SetChecked(store.Config.KeepRunningInTray) notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil) @@ -632,12 +645,19 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje store.Config.JobsDir = strings.TrimSpace(jobsDir.Text) store.Config.MaxLogFiles = files store.Config.MaxLogAgeDays = days + store.Config.StartOnLogin = startOnLogin.Checked store.Config.KeepRunningInTray = minimizeToTray.Checked store.Config.NotifyOnFailure = notifications.Checked if err := store.SaveConfig(); err != nil { settingsStatus.SetText("Save failed: " + err.Error()) return } + if err := core.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath); err != nil { + refreshAutostartStatus() + settingsStatus.SetText("Saved, autostart failed: " + err.Error()) + return + } + refreshAutostartStatus() // 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. @@ -656,6 +676,8 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje return container.NewPadded(container.NewVBox( widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + detailRow("Version", widget.NewLabel(core.Version)), + detailRow("Start on login", container.NewBorder(nil, nil, nil, autostartStatus, startOnLogin)), minimizeToTray, notifications, widget.NewSeparator(),