Stabilize packaging and scheduler storage

This commit is contained in:
mixeme
2026-06-14 23:23:14 +03:00
parent 4c11bb4f06
commit 414be2dfe9
19 changed files with 440 additions and 84 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
bin
dist
logs
pysentry.yaml
jobs.yaml
*.exe
+5
View File
@@ -1,7 +1,12 @@
# ---> Python
# ---> Go
bin/
dist/
cmd/pysentry/*.syso
*.exe
pysentry.yaml
jobs.yaml
logs/
# Byte-compiled / optimized / DLL files
__pycache__/
+22
View File
@@ -0,0 +1,22 @@
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
+156 -20
View File
@@ -1,37 +1,173 @@
# PySentry
PySentry is a cross-platform desktop scheduler inspired by cron.
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.
The project is starting with the GUI shell first, then the scheduling core.
## Features
- Native desktop GUI built with Fyne.
- Job storage in one clean YAML file.
- App settings in a separate YAML file.
- `@every` schedules and standard 5-field cron expressions.
- Manual and scheduled command runs.
- Per-run `.log` files with stdout/stderr.
- Log cleanup by maximum file count and maximum age.
- Global pause/resume for all job execution.
- Windows tray support.
## Requirements
- Go 1.22 or newer
- A C compiler for Fyne builds on Windows, for example MSYS2/MinGW-w64
Common:
## Run
- Go 1.22 or newer.
```powershell
go mod tidy
go run ./cmd/pysentry
Windows:
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin`.
Linux:
- A C compiler.
- Fyne native build dependencies, including OpenGL/X11 development packages.
On Debian/Ubuntu, the Linux dependencies are typically:
```bash
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
```
If Go is installed but not available in `PATH`, use the full path:
## Build
Windows:
```powershell
.\scripts\build-windows.ps1
```
The binary is written to:
```text
dist\windows\pysentry.exe
```
Linux:
```bash
chmod +x ./scripts/build-linux.sh
./scripts/build-linux.sh
```
The binary is written to:
```text
dist/linux/pysentry
```
Linux using Docker:
```powershell
.\scripts\build-linux-docker.ps1
```
The binary is copied to:
```text
dist\linux\pysentry
```
## Run From Source
Windows:
```powershell
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path
$env:CGO_ENABLED = '1'
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry
```
## Current shape
Linux:
```bash
CGO_ENABLED=1 go run ./cmd/pysentry
```
## Storage
PySentry creates its runtime files next to the executable by default.
`pysentry.yaml` stores application settings:
```yaml
jobs_dir: .
logs_dir: logs
max_log_files: 100
max_log_age_days: 30
keep_running_in_tray: true
notify_on_failure: true
```
`jobs.yaml` stores only job definitions:
```yaml
jobs:
- id: 1
name: Hello scheduler
folder: Examples
schedule: '@every 10s'
command: echo PySentry test job: scheduler is alive
enabled: true
```
Command output is written to separate files under `logs_dir`. File names include the run timestamp and job name, for example:
```text
20260614-224306_Hello_scheduler.log
```
## Schedules
Fast interval schedules:
```text
@every 10s
@every 5m
@every 1h30m
```
Standard 5-field cron schedules:
```text
*/5 * * * * every five minutes
0 2 * * * every day at 02:00
30 9 * * 1-5 weekdays at 09:30
```
## Using The App
1. Start PySentry.
2. Use `New job` to create a command.
3. Set `Schedule`, `Command`, optional `Folder`, and `Enabled`.
4. Use `Run now` for a manual test run.
5. Use `Pause` to disable one job.
6. Use `Pause all` as a global stop switch.
7. Open `History` to see whether a run was `Manual`, `Schedule`, or `UI`.
8. Open `Settings` to change `jobs_dir`, `logs_dir`, and log cleanup limits. Use `Browse` to choose directories.
Changing `jobs_dir` saves the current job list to the new directory.
## Project Layout
- `cmd/pysentry` starts the desktop app.
- `internal/app` contains the first Fyne-based interface prototype.
- `internal/core` contains YAML storage, command execution, and the first scheduler loop.
- Jobs can be created, edited, paused/resumed, run manually, and persisted to YAML.
- Settings are stored in `pysentry.yaml` next to the executable.
- Jobs are stored in one `jobs.yaml` file. The job directory is configured by `jobs_dir` and defaults to the executable directory.
- Command output is also written to per-run `.log` files in `logs_dir`. Log filenames include the run timestamp and job name.
- Log cleanup is controlled by `max_log_files` and `max_log_age_days`.
- The current scheduler supports `@every` schedules such as `@every 10s` and `@every 1m`.
- Run history records include a `trigger` value such as `Manual`, `Schedule`, or `UI`.
- Cron expression parsing is planned for the next phase.
- `internal/app` contains the GUI.
- `internal/core` contains YAML storage, command execution, scheduling, and log cleanup.
- `assets` contains app icons.
- `scripts` contains build helpers.
## Dependencies
PySentry keeps the direct dependency list intentionally small:
- `fyne.io/fyne/v2` for the native GUI.
- `github.com/robfig/cron/v3` for cron schedule parsing.
- `gopkg.in/yaml.v3` for YAML settings and jobs.
The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 996 KiB

+1
View File
@@ -4,6 +4,7 @@ go 1.22
require (
fyne.io/fyne/v2 v2.5.3
github.com/robfig/cron/v3 v3.0.1
gopkg.in/yaml.v3 v3.0.1
)
+2
View File
@@ -243,6 +243,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+39 -20
View File
@@ -1,9 +1,8 @@
package app
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -27,6 +26,9 @@ const noFolder = "No folder"
type job = core.Job
type event = core.RunRecord
//go:embed assets/pysentry-icon.png
var iconBytes []byte
func Run() {
a := app.NewWithID(appID)
a.SetIcon(loadAppIcon())
@@ -39,19 +41,7 @@ func Run() {
}
func loadAppIcon() fyne.Resource {
candidates := []string{}
if executable, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(executable), "assets", "pysentry-icon.png"))
}
if workingDir, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(workingDir, "assets", "pysentry-icon.png"))
}
for _, path := range candidates {
if resource, err := fyne.LoadResourceFromPath(path); err == nil {
return resource
}
}
return theme.ComputerIcon()
return fyne.NewStaticResource("pysentry-icon.png", iconBytes)
}
func configureSystemTray(a fyne.App, w fyne.Window) {
@@ -379,7 +369,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))),
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(store)),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, store, &jobs)),
)
tabs.SetTabLocation(container.TabLocationTop)
@@ -557,14 +547,22 @@ func newHistoryView(events *[]event) *fyne.Container {
return container.NewPadded(list)
}
func settingsView(store *core.Store) fyne.CanvasObject {
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
runOnStartup := widget.NewCheck("Start PySentry when I sign in", nil)
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)
notifications.SetChecked(store.Config.NotifyOnFailure)
jobsDir := widget.NewEntry()
jobsDir.SetText(store.Config.JobsDir)
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, jobsDir)
})
logsDir := widget.NewEntry()
logsDir.SetText(store.Config.LogsDir)
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir)
})
maxLogFiles := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogAgeDays := widget.NewEntry()
@@ -583,6 +581,15 @@ func settingsView(store *core.Store) fyne.CanvasObject {
return
}
store.Config.LogsDir = strings.TrimSpace(logsDir.Text)
if strings.TrimSpace(jobsDir.Text) == "" {
settingsStatus.SetText("Jobs directory is required")
return
}
if strings.TrimSpace(logsDir.Text) == "" {
settingsStatus.SetText("Logs directory is required")
return
}
store.Config.JobsDir = strings.TrimSpace(jobsDir.Text)
store.Config.MaxLogFiles = files
store.Config.MaxLogAgeDays = days
store.Config.KeepRunningInTray = minimizeToTray.Checked
@@ -591,6 +598,10 @@ func settingsView(store *core.Store) fyne.CanvasObject {
settingsStatus.SetText("Save failed: " + err.Error())
return
}
if err := store.SaveJobs(*jobs); err != nil {
settingsStatus.SetText("Jobs save failed: " + err.Error())
return
}
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
return
@@ -606,9 +617,8 @@ func settingsView(store *core.Store) fyne.CanvasObject {
widget.NewSeparator(),
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
detailRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
detailRow("Jobs YAML", widget.NewLabel(store.Paths.JobsPath)),
detailRow("Jobs directory", widget.NewLabel(store.Paths.JobsDir)),
detailRow("Logs directory", logsDir),
detailRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
detailRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
detailRow("Max log files", maxLogFiles),
detailRow("Max log age days", maxLogAgeDays),
saveSettings,
@@ -618,3 +628,12 @@ func settingsView(store *core.Store) fyne.CanvasObject {
widget.NewLabel("Current core supports @every schedules. Cron expressions come next."),
))
}
func chooseFolder(w fyne.Window, target *widget.Entry) {
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
target.SetText(uri.Path())
}, w)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

+5 -5
View File
@@ -22,11 +22,11 @@ type Job struct {
Schedule string `yaml:"schedule"`
Command string `yaml:"command"`
Enabled bool `yaml:"enabled"`
LastRun string `yaml:"last_run,omitempty"`
NextRun string `yaml:"next_run,omitempty"`
LastState string `yaml:"last_state,omitempty"`
Logs []RunRecord `yaml:"activity,omitempty"`
Output string `yaml:"last_output,omitempty"`
LastRun string `yaml:"-"`
NextRun string `yaml:"-"`
LastState string `yaml:"-"`
Logs []RunRecord `yaml:"-"`
Output string `yaml:"-"`
nextDue time.Time
}
+19 -8
View File
@@ -5,8 +5,12 @@ import (
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
)
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
type Scheduler struct {
store *Store
jobs *[]Job
@@ -146,24 +150,31 @@ func (s *Scheduler) resetNextRuns(now time.Time) {
}
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
interval, ok := parseEvery(job.Schedule)
next, ok := nextRunTime(job.Schedule, from)
if !ok {
job.NextRun = "Unsupported schedule"
job.NextRun = "Invalid schedule"
job.nextDue = time.Time{}
return
}
job.nextDue = from.Add(interval)
job.nextDue = next
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
}
func parseEvery(schedule string) (time.Duration, bool) {
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
schedule = strings.TrimSpace(schedule)
if !strings.HasPrefix(schedule, "@every ") {
return 0, false
if schedule == "" {
return time.Time{}, false
}
if strings.HasPrefix(schedule, "@every ") {
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
if err != nil || interval <= 0 {
return 0, false
return time.Time{}, false
}
return interval, true
return from.Add(interval), true
}
parsed, err := cronParser.Parse(schedule)
if err != nil {
return time.Time{}, false
}
return parsed.Next(from), true
}
+29
View File
@@ -0,0 +1,29 @@
package core
import (
"testing"
"time"
)
func TestNextRunTimeSupportsEvery(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
next, ok := nextRunTime("@every 10s", from)
if !ok {
t.Fatal("expected @every schedule to parse")
}
if want := from.Add(10 * time.Second); !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
func TestNextRunTimeSupportsCron(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
next, ok := nextRunTime("*/5 * * * *", from)
if !ok {
t.Fatal("expected cron schedule to parse")
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
+42 -13
View File
@@ -36,6 +36,10 @@ func OpenStore() (*Store, []Job, error) {
if err != nil {
return nil, nil, err
}
normalizeJobs(jobs)
if err := store.SaveJobs(jobs); err != nil {
return nil, nil, err
}
return store, jobs, nil
}
@@ -93,6 +97,7 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
func loadOrCreateJobs(path string) ([]Job, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
jobs := defaultJobs()
normalizeJobs(jobs)
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
}
@@ -107,6 +112,42 @@ func loadOrCreateJobs(path string) ([]Job, error) {
return file.Jobs, nil
}
func normalizeJobs(jobs []Job) {
next := 1
for index := range jobs {
job := &jobs[index]
if job.ID <= 0 {
job.ID = next
}
if job.ID >= next {
next = job.ID + 1
}
if strings.TrimSpace(job.Name) == "" {
job.Name = "Untitled job"
}
if strings.TrimSpace(job.Schedule) == "" {
job.Schedule = "@every 1m"
}
if strings.TrimSpace(job.Command) == "" {
job.Command = echoCommand("PySentry job ran")
}
if job.LastRun == "" {
job.LastRun = "Never"
}
if job.Output == "" {
job.Output = "No command output captured yet."
}
if job.Enabled {
job.LastState = "Ready"
job.NextRun = "After start"
} else {
job.LastState = "Paused"
job.NextRun = "Paused"
}
job.Logs = nil
}
}
func resolveJobsDir(appDir string, jobsDir string) string {
return resolveConfiguredDir(appDir, jobsDir)
}
@@ -144,22 +185,14 @@ func defaultJobs() []Job {
Schedule: "@every 10s",
Command: echoCommand("PySentry test job: scheduler is alive"),
Enabled: true,
LastRun: "Never",
NextRun: "After start",
LastState: "Ready",
Output: "No command output captured yet.",
},
{
ID: 2,
Name: "Write timestamp",
Folder: "Examples",
Schedule: "@every 15s",
Schedule: "*/1 * * * *",
Command: echoCommand("PySentry test job: timestamp command ran"),
Enabled: true,
LastRun: "Never",
NextRun: "After start",
LastState: "Ready",
Output: "No command output captured yet.",
},
{
ID: 3,
@@ -167,10 +200,6 @@ func defaultJobs() []Job {
Schedule: "@every 1m",
Command: echoCommand("This paused sample should not run until enabled"),
Enabled: false,
LastRun: "Never",
NextRun: "Paused",
LastState: "Paused",
Output: "No command output captured yet.",
},
}
}
+38
View File
@@ -0,0 +1,38 @@
package core
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
LastRun: "2026-06-14 12:00:00",
NextRun: "2026-06-14 12:00:10",
LastState: "OK",
Output: "stdout: ok",
Logs: []RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
},
},
}
data, err := yaml.Marshal(JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
if strings.Contains(text, unwanted) {
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+1
View File
@@ -0,0 +1 @@
IDI_ICON1 ICON "packaging/windows/pysentry.ico"
+13
View File
@@ -0,0 +1,13 @@
param(
[string]$Output = "dist\linux\pysentry"
)
$ErrorActionPreference = "Stop"
docker build -f Dockerfile.linux -t pysentry-linux-builder .
$containerId = docker create pysentry-linux-builder
New-Item -ItemType Directory -Force -Path (Split-Path $Output) | Out-Null
docker cp "${containerId}:/out/pysentry" $Output
docker rm $containerId | Out-Null
Write-Host "Built $Output"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
output="${1:-dist/linux/pysentry}"
mkdir -p "$(dirname "$output")"
export CGO_ENABLED=1
export GOOS=linux
export GOARCH=amd64
go build -trimpath -ldflags "-s -w" -o "$output" ./cmd/pysentry
echo "Built $output"
+30
View File
@@ -0,0 +1,30 @@
param(
[string]$Output = "dist\windows\pysentry.exe"
)
$ErrorActionPreference = "Stop"
$go = "${env:ProgramFiles}\Go\bin\go.exe"
if (-not (Test-Path $go)) {
$go = "go"
}
$msys2Bin = "C:\msys64\ucrt64\bin"
if (Test-Path $msys2Bin) {
$env:Path = "$msys2Bin;$env:Path"
}
$env:CGO_ENABLED = "1"
$env:GOOS = "windows"
$env:GOARCH = "amd64"
New-Item -ItemType Directory -Force -Path (Split-Path $Output) | Out-Null
$windres = Get-Command windres.exe -ErrorAction SilentlyContinue
if ($windres) {
& $windres.Source -O coff -o .\cmd\pysentry\rsrc_windows_amd64.syso .\packaging\windows\pysentry.rc
}
& $go build -trimpath -ldflags "-s -w" -o $Output .\cmd\pysentry
Write-Host "Built $Output"