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
# 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
+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.
- 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-<version>-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.
+16 -8
View File
@@ -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.
+6 -4
View File
@@ -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.
+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
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
+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"`
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"`
}
+11 -9
View File
@@ -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
}
+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
// 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
+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,
}
}
+49 -18
View File
@@ -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) {
+2 -1
View File
@@ -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,
},
+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.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(),