Add autostart status and release builds

This commit is contained in:
mixeme
2026-06-15 07:35:52 +03:00
parent 47e2ba7272
commit 5727e13f23
18 changed files with 443 additions and 72 deletions
+27 -10
View File
@@ -4,11 +4,19 @@ FROM golang:1.22-bookworm
# compiler plus OpenGL/X11 headers. --no-install-recommends keeps the image from # compiler plus OpenGL/X11 headers. --no-install-recommends keeps the image from
# pulling in unrelated desktop packages that are not needed for compilation. # pulling in unrelated desktop packages that are not needed for compilation.
RUN apt-get update && \ RUN apt-get update && \
dpkg --add-architecture arm64 && \
apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
gcc \ gcc \
gcc-aarch64-linux-gnu \
gcc-mingw-w64-x86-64 \
binutils-mingw-w64-x86-64 \
pkg-config \
libgl1-mesa-dev \ libgl1-mesa-dev \
xorg-dev && \ xorg-dev \
libgl1-mesa-dev:arm64 \
xorg-dev:arm64 && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
@@ -20,12 +28,21 @@ RUN go mod download
COPY . . COPY . .
# CGO is required by Fyne. The first Linux package target is linux/amd64; other # CGO is required by Fyne. This builder produces the release artifacts from
# architectures can be added later as separate, explicit build targets. # Linux: Linux amd64, Linux arm64, and a Windows amd64 binary cross-compiled with
ENV CGO_ENABLED=1 # MinGW. The Windows resource is generated inside the container so Explorer still
ENV GOOS=linux # sees the application icon.
ENV GOARCH=amd64 RUN version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" && \
version="${version:-0.0.0-dev}" && \
# -trimpath removes host paths from the binary, and -s -w strips symbol/debug mkdir -p /out/linux /out/windows && \
# tables to keep the produced desktop executable smaller. CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
RUN go build -trimpath -ldflags "-s -w" -o /out/pysentry ./cmd/pysentry 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
+76 -11
View File
@@ -15,6 +15,7 @@ PySentry is being designed and implemented with assistance from OpenAI Codex.
- Log cleanup by maximum file count and maximum age. - Log cleanup by maximum file count and maximum age.
- Global pause/resume for all job execution. - Global pause/resume for all job execution.
- Windows tray support. - Windows tray support.
- Version shown in the window title, Settings, and build artifact names.
## Requirements ## Requirements
@@ -44,9 +45,11 @@ sudo apt install golang gcc libgl1-mesa-dev xorg-dev
Windows: Windows:
```powershell ```powershell
# Builds dist\windows\pysentry.exe. The script adds MSYS2 UCRT64 to PATH for # Builds dist\windows\pysentry-<version>-windows-amd64.exe. The script changes
# this process only, embeds the Windows icon when windres is available, and uses # to the repository root first, so double-clicking it from Explorer works. It
# the Windows GUI subsystem so no console window opens at startup. # 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 .\scripts\build-windows.bat
``` ```
@@ -56,7 +59,7 @@ The binary is written to:
```text ```text
# GUI executable produced by scripts\build-windows.bat. # GUI executable produced by scripts\build-windows.bat.
dist\windows\pysentry.exe dist\windows\pysentry-0.1.0-windows-amd64.exe
``` ```
Linux: Linux:
@@ -71,14 +74,15 @@ The binary is written to:
```text ```text
# Linux executable produced by scripts/build-linux.sh. # Linux executable produced by scripts/build-linux.sh.
dist/linux/pysentry dist/linux/pysentry-0.1.0-linux-amd64
``` ```
Linux using Docker: Linux using Docker:
```bash ```bash
# Builds the same Linux binary inside Docker, useful from Windows hosts or CI # Builds the Linux binary inside Docker using the image tag
# where the native Linux/Fyne packages are not installed locally. # 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 chmod +x ./scripts/build-linux-docker.sh
./scripts/build-linux-docker.sh ./scripts/build-linux-docker.sh
``` ```
@@ -87,7 +91,30 @@ The binary is copied to:
```text ```text
# Linux executable copied out of the Docker build image. # 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 ## Run From Source
@@ -120,8 +147,8 @@ PySentry creates its runtime files next to the executable by default.
`pysentry.yaml` stores application settings: `pysentry.yaml` stores application settings:
```yaml ```yaml
# Directory containing jobs.yaml. "." means "the folder where pysentry.exe lives"; # Directory containing jobs.yaml. "." means "the folder where the PySentry
# an absolute path can be used when jobs should live elsewhere. # executable lives"; an absolute path can be used when jobs should live elsewhere.
jobs_dir: . jobs_dir: .
# Directory for per-run command output logs. Relative paths are resolved against # 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. # Delete .log files older than this many days during cleanup.
max_log_age_days: 30 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. # Closing the window hides it to the tray instead of stopping the scheduler.
keep_running_in_tray: true keep_running_in_tray: true
@@ -159,7 +189,7 @@ jobs:
folder: Examples folder: Examples
# Either @every with a Go duration, or a standard five-field cron expression. # 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 passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux.
command: echo PySentry test job: scheduler is alive 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. 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 ## Project Layout
- `cmd/pysentry` starts the desktop app. - `cmd/pysentry` starts the desktop app.
+16 -8
View File
@@ -2,19 +2,27 @@
set -euo pipefail set -euo pipefail
# Optional first argument mirrors build-linux.sh. The Docker build still writes # Optional first argument mirrors build-linux.sh. The Docker build still writes
# the final artifact into the local dist/ tree, not into the container. # the final artifact into the local dist/ tree, not into the container. The
output="${1:-dist/linux/pysentry}" # 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 # Dockerfile contains the native packages required by Fyne. Keeping that
# environment in Docker makes Linux builds repeatable from Windows hosts and CI. # 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")" mkdir -p "$(dirname "$output")"
docker cp "${container_id}:/out/pysentry" "$output" docker cp "${container_id}:/out/linux/pysentry-${version}-linux-amd64" "$output"
docker rm "$container_id" >/dev/null
# Icons are embedded in the Go binary, so there is no assets directory to copy # Icons are embedded in the Go binary, so there is no assets directory to copy
# after extracting the Linux executable. # after extracting the Linux executable.
+6 -4
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Optional first argument lets a developer or CI job choose the output path. # Optional first argument lets a developer or CI job choose the output path. The
# dist/linux/pysentry is the default so generated binaries stay outside src/. # default includes the application version and target platform.
output="${1:-dist/linux/pysentry}" 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")" mkdir -p "$(dirname "$output")"
# Fyne needs CGO for its native desktop backend. The script pins the target to # 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 # -trimpath removes local machine paths from debug/build metadata. -s -w strips
# symbol/debug tables to keep the desktop binary smaller. # 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 # The application icon is embedded by Go, so the Linux build does not need a
# sidecar assets directory beside the executable. # sidecar assets directory beside the executable.
+22
View File
@@ -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
+11 -2
View File
@@ -1,11 +1,20 @@
@echo off @echo off
setlocal enabledelayedexpansion 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 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 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. REM stays clean and the old bin\ folder is no longer needed.
set "OUTPUT=%~1" 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 Prefer the standard Go installer path on Windows, but fall back to PATH for
REM machines where Go was installed by another package manager. 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 -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 size, and -H=windowsgui prevents a separate console window from opening when
REM the GUI app starts from Explorer or a shortcut. 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 if errorlevel 1 exit /b 1
REM Icons are embedded into the executable, so no assets directory is copied next REM Icons are embedded into the executable, so no assets directory is copied next
+89
View File
@@ -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
}
+19
View File
@@ -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"
}
+49
View File
@@ -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"
}
+1
View File
@@ -10,6 +10,7 @@ type Config struct {
LogsDir string `yaml:"logs_dir"` LogsDir string `yaml:"logs_dir"`
MaxLogFiles int `yaml:"max_log_files"` MaxLogFiles int `yaml:"max_log_files"`
MaxLogAgeDays int `yaml:"max_log_age_days"` MaxLogAgeDays int `yaml:"max_log_age_days"`
StartOnLogin bool `yaml:"start_on_login"`
KeepRunningInTray bool `yaml:"keep_running_in_tray"` KeepRunningInTray bool `yaml:"keep_running_in_tray"`
NotifyOnFailure bool `yaml:"notify_on_failure"` NotifyOnFailure bool `yaml:"notify_on_failure"`
} }
+2
View File
@@ -19,6 +19,7 @@ const (
// storage locations. Keeping resolved paths in one struct prevents the GUI and // storage locations. Keeping resolved paths in one struct prevents the GUI and
// scheduler from interpreting relative directories differently. // scheduler from interpreting relative directories differently.
type Paths struct { type Paths struct {
ExecutablePath string
AppDir string AppDir string
ConfigPath string ConfigPath string
JobsDir string JobsDir string
@@ -39,6 +40,7 @@ func ResolvePaths() (Paths, error) {
appDir := filepath.Dir(executable) appDir := filepath.Dir(executable)
configPath := filepath.Join(appDir, ConfigFileName) configPath := filepath.Join(appDir, ConfigFileName)
return Paths{ return Paths{
ExecutablePath: executable,
AppDir: appDir, AppDir: appDir,
ConfigPath: configPath, ConfigPath: configPath,
JobsDir: appDir, JobsDir: appDir,
+1
View File
@@ -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 // than argv-based execution, but it is the expected behavior for a cron-like
// tool that supports redirection, environment expansion, and shell builtins. // tool that supports redirection, environment expansion, and shell builtins.
command := shellCommand(runCtx, job.Command) command := shellCommand(runCtx, job.Command)
configureHiddenWindow(command)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
command.Stdout = &stdout command.Stdout = &stdout
+11
View File
@@ -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.
}
+16
View File
@@ -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,
}
}
+46 -15
View File
@@ -85,22 +85,17 @@ func (s *Scheduler) SetPaused(paused bool) {
_ = s.store.SaveJobs(*s.jobs) _ = s.store.SaveJobs(*s.jobs)
} }
func (s *Scheduler) RunNow(index int) RunRecord { func (s *Scheduler) RunNow(index int) bool {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if index < 0 || index >= len(*s.jobs) { 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 // Manual runs share the same runner and log writer as scheduled runs. The
// Trigger field is the only difference, which keeps History comparable and // Trigger field is the only difference, which keeps History comparable and
// prevents "Run now" from becoming a separate behavior path. // prevents "Run now" from becoming a separate behavior path.
record := RunJob(s.ctx, job, "Manual", s.store.Paths.LogsDir) return s.startRunLocked(index, "Manual")
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
} }
func (s *Scheduler) RefreshSchedule(index int) { func (s *Scheduler) RefreshSchedule(index int) {
@@ -123,7 +118,6 @@ func (s *Scheduler) RefreshSchedule(index int) {
} }
func (s *Scheduler) tick(now time.Time) { func (s *Scheduler) tick(now time.Time) {
var record RunRecord
var changed bool var changed bool
s.mu.Lock() 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; // commands in the GUI process and keeps the first version predictable;
// a future worker pool can add concurrency once cancellation and status // a future worker pool can add concurrency once cancellation and status
// reporting are more explicit. // reporting are more explicit.
record = RunJob(s.ctx, job, "Schedule", s.store.Paths.LogsDir) changed = s.startRunLocked(index, "Schedule")
s.prepareNextRun(job, time.Now())
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
changed = true
break break
} }
} }
if changed { s.mu.Unlock()
_ = changed
}
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.store.SaveJobs(*s.jobs)
} }
s.mu.Unlock() s.mu.Unlock()
if changed && s.onChange != nil { if s.onChange != nil {
s.onChange(record) 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) { func (s *Scheduler) resetNextRuns(now time.Time) {
+2 -1
View File
@@ -72,6 +72,7 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
LogsDir: "logs", LogsDir: "logs",
MaxLogFiles: 100, MaxLogFiles: 100,
MaxLogAgeDays: 30, MaxLogAgeDays: 30,
StartOnLogin: false,
KeepRunningInTray: true, KeepRunningInTray: true,
NotifyOnFailure: true, NotifyOnFailure: true,
} }
@@ -207,7 +208,7 @@ func defaultJobs() []Job {
ID: 1, ID: 1,
Name: "Hello scheduler", Name: "Hello scheduler",
Folder: "Examples", Folder: "Examples",
Schedule: "@every 10s", Schedule: "@every 1m",
Command: echoCommand("PySentry test job: scheduler is alive"), Command: echoCommand("PySentry test job: scheduler is alive"),
Enabled: true, Enabled: true,
}, },
+6
View File
@@ -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"
+31 -9
View File
@@ -35,7 +35,7 @@ func Run() {
a := app.NewWithID(appID) a := app.NewWithID(appID)
a.SetIcon(loadAppIcon()) a.SetIcon(loadAppIcon())
w := a.NewWindow("PySentry") w := a.NewWindow("PySentry " + core.Version)
configureSystemTray(a, w) configureSystemTray(a, w)
w.Resize(fyne.NewSize(1120, 720)) w.Resize(fyne.NewSize(1120, 720))
w.SetContent(newMainView(w)) 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. // against the theme when it is placed inside a scroll container.
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160)) commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
history := newHistoryView(&events) history := newHistoryView(&events)
selectedLogs := append([]event(nil), jobs[selected].Logs...)
jobLogs := widget.NewList( jobLogs := widget.NewList(
func() int { func() int {
if selected < 0 || selected >= len(jobs) { return len(selectedLogs)
return 0
}
return len(jobs[selected].Logs)
}, },
func() fyne.CanvasObject { return widget.NewLabel("log") }, func() fyne.CanvasObject { return widget.NewLabel("log") },
func(id widget.ListItemID, item fyne.CanvasObject) { 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("") nextRun.SetText("")
state.SetText("") state.SetText("")
commandOutput.SetText("") commandOutput.SetText("")
selectedLogs = nil
return return
} }
selected = index selected = index
@@ -141,6 +140,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
nextRun.SetText(current.NextRun) nextRun.SetText(current.NextRun)
state.SetText(current.LastState) state.SetText(current.LastState)
commandOutput.SetText(current.Output) commandOutput.SetText(current.Output)
selectedLogs = append(selectedLogs[:0], current.Logs...)
} }
refresh := func() { refresh := func() {
// Several callbacks mutate jobs, filters, and event history. A single // 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) dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
return return
} }
ran := scheduler.RunNow(selected) if !scheduler.RunNow(selected) {
if ran.Time == "" {
return return
} }
events = append([]event{ran}, events...)
list.Refresh() list.Refresh()
refresh() refresh()
}) })
@@ -589,6 +587,21 @@ func newHistoryView(events *[]event) *fyne.Container {
} }
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject { 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 := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray) minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil) 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.JobsDir = strings.TrimSpace(jobsDir.Text)
store.Config.MaxLogFiles = files store.Config.MaxLogFiles = files
store.Config.MaxLogAgeDays = days store.Config.MaxLogAgeDays = days
store.Config.StartOnLogin = startOnLogin.Checked
store.Config.KeepRunningInTray = minimizeToTray.Checked store.Config.KeepRunningInTray = minimizeToTray.Checked
store.Config.NotifyOnFailure = notifications.Checked store.Config.NotifyOnFailure = notifications.Checked
if err := store.SaveConfig(); err != nil { if err := store.SaveConfig(); err != nil {
settingsStatus.SetText("Save failed: " + err.Error()) settingsStatus.SetText("Save failed: " + err.Error())
return 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 // When the jobs directory changes, save the currently loaded jobs to the
// newly resolved path immediately. That makes the setting visible on disk // newly resolved path immediately. That makes the setting visible on disk
// without requiring a restart or a separate migration command. // 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( return container.NewPadded(container.NewVBox(
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), 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, minimizeToTray,
notifications, notifications,
widget.NewSeparator(), widget.NewSeparator(),