Document design choices and standardize Dockerfile
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
|||||||
|
FROM golang:1.22-bookworm
|
||||||
|
|
||||||
|
# Fyne links against native desktop libraries, so the container must include a C
|
||||||
|
# compiler plus OpenGL/X11 headers. --no-install-recommends keeps the image from
|
||||||
|
# pulling in unrelated desktop packages that are not needed for compilation.
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
gcc \
|
||||||
|
libgl1-mesa-dev \
|
||||||
|
xorg-dev && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy module files first so Docker can cache downloaded dependencies while the
|
||||||
|
# application source changes. This makes repeated local builds much faster.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# CGO is required by Fyne. The first Linux package target is linux/amd64; other
|
||||||
|
# architectures can be added later as separate, explicit build targets.
|
||||||
|
ENV CGO_ENABLED=1
|
||||||
|
ENV GOOS=linux
|
||||||
|
ENV GOARCH=amd64
|
||||||
|
|
||||||
|
# -trimpath removes host paths from the binary, and -s -w strips symbol/debug
|
||||||
|
# tables to keep the produced desktop executable smaller.
|
||||||
|
RUN go build -trimpath -ldflags "-s -w" -o /out/pysentry ./cmd/pysentry
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
FROM golang:1.22-bookworm
|
|
||||||
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
gcc \
|
|
||||||
libgl1-mesa-dev \
|
|
||||||
xorg-dev && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ENV CGO_ENABLED=1
|
|
||||||
ENV GOOS=linux
|
|
||||||
ENV GOARCH=amd64
|
|
||||||
|
|
||||||
RUN go build -trimpath -ldflags "-s -w" -o /out/pysentry ./cmd/pysentry
|
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
PySentry is a cross-platform desktop scheduler inspired by cron. It provides a native GUI for creating, grouping, pausing, running, and monitoring scheduled shell commands.
|
PySentry is a cross-platform desktop scheduler inspired by cron. It provides a native GUI for creating, grouping, pausing, running, and monitoring scheduled shell commands.
|
||||||
|
|
||||||
|
PySentry is being designed and implemented with assistance from OpenAI Codex.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Native desktop GUI built with Fyne.
|
- Native desktop GUI built with Fyne.
|
||||||
@@ -32,6 +34,8 @@ Linux:
|
|||||||
On Debian/Ubuntu, the Linux dependencies are typically:
|
On Debian/Ubuntu, the Linux dependencies are typically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Go builds the application, gcc is required by CGO/Fyne, and the OpenGL/X11
|
||||||
|
# development packages provide the native desktop headers used by Fyne.
|
||||||
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
|
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,6 +44,9 @@ sudo apt install golang gcc libgl1-mesa-dev xorg-dev
|
|||||||
Windows:
|
Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
# Builds dist\windows\pysentry.exe. The script adds MSYS2 UCRT64 to PATH for
|
||||||
|
# this process only, embeds the Windows icon when windres is available, and uses
|
||||||
|
# the Windows GUI subsystem so no console window opens at startup.
|
||||||
.\scripts\build-windows.bat
|
.\scripts\build-windows.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,12 +55,14 @@ The Windows build is created as a GUI application, so it does not open a termina
|
|||||||
The binary is written to:
|
The binary is written to:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
# GUI executable produced by scripts\build-windows.bat.
|
||||||
dist\windows\pysentry.exe
|
dist\windows\pysentry.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux:
|
Linux:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Make the helper executable once, then build a linux/amd64 Fyne binary.
|
||||||
chmod +x ./scripts/build-linux.sh
|
chmod +x ./scripts/build-linux.sh
|
||||||
./scripts/build-linux.sh
|
./scripts/build-linux.sh
|
||||||
```
|
```
|
||||||
@@ -61,12 +70,15 @@ chmod +x ./scripts/build-linux.sh
|
|||||||
The binary is written to:
|
The binary is written to:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
# Linux executable produced by scripts/build-linux.sh.
|
||||||
dist/linux/pysentry
|
dist/linux/pysentry
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux using Docker:
|
Linux using Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Builds the same Linux binary inside Docker, useful from Windows hosts or CI
|
||||||
|
# where the native Linux/Fyne packages are not installed locally.
|
||||||
chmod +x ./scripts/build-linux-docker.sh
|
chmod +x ./scripts/build-linux-docker.sh
|
||||||
./scripts/build-linux-docker.sh
|
./scripts/build-linux-docker.sh
|
||||||
```
|
```
|
||||||
@@ -74,6 +86,7 @@ chmod +x ./scripts/build-linux-docker.sh
|
|||||||
The binary is copied to:
|
The binary is copied to:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
# Linux executable copied out of the Docker build image.
|
||||||
dist\linux\pysentry
|
dist\linux\pysentry
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -82,14 +95,21 @@ dist\linux\pysentry
|
|||||||
Windows:
|
Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
# Fyne requires CGO on Windows. MSYS2 UCRT64 provides the C compiler and native
|
||||||
|
# libraries used by the desktop backend.
|
||||||
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path
|
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path
|
||||||
$env:CGO_ENABLED = '1'
|
$env:CGO_ENABLED = '1'
|
||||||
|
|
||||||
|
# go run starts the app from source. Use scripts\build-windows.bat when you need
|
||||||
|
# a standalone .exe without a console window.
|
||||||
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry
|
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux:
|
Linux:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# CGO must stay enabled because the Fyne GUI links against native Linux desktop
|
||||||
|
# libraries.
|
||||||
CGO_ENABLED=1 go run ./cmd/pysentry
|
CGO_ENABLED=1 go run ./cmd/pysentry
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -100,11 +120,25 @@ PySentry creates its runtime files next to the executable by default.
|
|||||||
`pysentry.yaml` stores application settings:
|
`pysentry.yaml` stores application settings:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# Directory containing jobs.yaml. "." means "the folder where pysentry.exe lives";
|
||||||
|
# an absolute path can be used when jobs should live elsewhere.
|
||||||
jobs_dir: .
|
jobs_dir: .
|
||||||
|
|
||||||
|
# Directory for per-run command output logs. Relative paths are resolved against
|
||||||
|
# the program folder, just like jobs_dir.
|
||||||
logs_dir: logs
|
logs_dir: logs
|
||||||
|
|
||||||
|
# Keep at most this many .log files after cleanup. Newest logs are preserved.
|
||||||
max_log_files: 100
|
max_log_files: 100
|
||||||
|
|
||||||
|
# Delete .log files older than this many days during cleanup.
|
||||||
max_log_age_days: 30
|
max_log_age_days: 30
|
||||||
|
|
||||||
|
# Closing the window hides it to the tray instead of stopping the scheduler.
|
||||||
keep_running_in_tray: true
|
keep_running_in_tray: true
|
||||||
|
|
||||||
|
# Reserved for desktop failure notifications; the setting is stored now so the
|
||||||
|
# UI and config format do not need to change when notifications are wired fully.
|
||||||
notify_on_failure: true
|
notify_on_failure: true
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,17 +146,32 @@ notify_on_failure: true
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
jobs:
|
jobs:
|
||||||
|
# A harmless sample job created on first run so the scheduler can be tested
|
||||||
|
# immediately. Runtime fields such as last run time, next run time, and command
|
||||||
|
# output are intentionally not stored here; they are displayed in the GUI and
|
||||||
|
# written to separate log files.
|
||||||
- id: 1
|
- id: 1
|
||||||
|
# Human-readable name shown in the jobs list and used in log file names.
|
||||||
name: Hello scheduler
|
name: Hello scheduler
|
||||||
|
|
||||||
|
# Optional grouping label. Omit it or leave it empty to put the job under
|
||||||
|
# the "No folder" filter.
|
||||||
folder: Examples
|
folder: Examples
|
||||||
|
|
||||||
|
# Either @every with a Go duration, or a standard five-field cron expression.
|
||||||
schedule: '@every 10s'
|
schedule: '@every 10s'
|
||||||
|
|
||||||
|
# Command passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux.
|
||||||
command: echo PySentry test job: scheduler is alive
|
command: echo PySentry test job: scheduler is alive
|
||||||
|
|
||||||
|
# Disabled jobs remain in jobs.yaml but are skipped by the scheduler.
|
||||||
enabled: true
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
Command output is written to separate files under `logs_dir`. File names include the run timestamp and job name, for example:
|
Command output is written to separate files under `logs_dir`. File names include the run timestamp and job name, for example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
# Format: YYYYMMDD-HHMMSS_<sanitized job name>.log
|
||||||
20260614-224306_Hello_scheduler.log
|
20260614-224306_Hello_scheduler.log
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -131,6 +180,7 @@ Command output is written to separate files under `logs_dir`. File names include
|
|||||||
Fast interval schedules:
|
Fast interval schedules:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
# Go duration syntax after @every; useful for tests and simple intervals.
|
||||||
@every 10s
|
@every 10s
|
||||||
@every 5m
|
@every 5m
|
||||||
@every 1h30m
|
@every 1h30m
|
||||||
@@ -139,6 +189,7 @@ Fast interval schedules:
|
|||||||
Standard 5-field cron schedules:
|
Standard 5-field cron schedules:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
# Standard five-field cron: minute hour day-of-month month day-of-week.
|
||||||
*/5 * * * * every five minutes
|
*/5 * * * * every five minutes
|
||||||
0 2 * * * every day at 02:00
|
0 2 * * * every day at 02:00
|
||||||
30 9 * * 1-5 weekdays at 09:30
|
30 9 * * 1-5 weekdays at 09:30
|
||||||
|
|||||||
@@ -6,9 +6,21 @@ import (
|
|||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The application icon is embedded into the binary instead of being loaded from
|
||||||
|
// an assets directory at runtime. That keeps the Windows/Linux distribution to a
|
||||||
|
// single executable and avoids the common failure mode where the app starts with
|
||||||
|
// a generic icon because a sidecar PNG was not copied with the binary.
|
||||||
|
//
|
||||||
|
// The blank import enables the compiler directive below; no runtime package
|
||||||
|
// initialization from embed is required.
|
||||||
|
//
|
||||||
//go:embed pysentry-icon.png
|
//go:embed pysentry-icon.png
|
||||||
var iconBytes []byte
|
var iconBytes []byte
|
||||||
|
|
||||||
func Icon() fyne.Resource {
|
func Icon() fyne.Resource {
|
||||||
|
// Fyne accepts resources from memory, so the same embedded PNG can be used
|
||||||
|
// for the window icon and tray icon. The Windows Explorer icon is still added
|
||||||
|
// by the build script through the .ico resource, because Explorer reads PE
|
||||||
|
// resources rather than Fyne runtime state.
|
||||||
return fyne.NewStaticResource("pysentry-icon.png", iconBytes)
|
return fyne.NewStaticResource("pysentry-icon.png", iconBytes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,8 @@ package main
|
|||||||
import "github.com/pysentry/pysentry/src/gui"
|
import "github.com/pysentry/pysentry/src/gui"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// The executable entry point intentionally delegates all startup work to the
|
||||||
|
// GUI package. Keeping main small makes it easier to add platform-specific
|
||||||
|
// packaging later without mixing window setup, storage, and scheduler logic.
|
||||||
gui.Run()
|
gui.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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}"
|
output="${1:-dist/linux/pysentry}"
|
||||||
|
|
||||||
docker build -f Dockerfile.linux -t pysentry-linux-builder .
|
# Dockerfile contains the native packages required by Fyne. Keeping that
|
||||||
|
# environment in Docker makes Linux builds repeatable from Windows hosts and CI.
|
||||||
|
docker build -f Dockerfile -t pysentry-linux-builder .
|
||||||
|
|
||||||
|
# The image build produces /out/pysentry. A temporary container is used only as a
|
||||||
|
# convenient way to copy that file out; the app is not run inside the container.
|
||||||
container_id="$(docker create pysentry-linux-builder)"
|
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/pysentry" "$output"
|
||||||
docker rm "$container_id" >/dev/null
|
docker rm "$container_id" >/dev/null
|
||||||
|
|
||||||
|
# Icons are embedded in the Go binary, so there is no assets directory to copy
|
||||||
|
# after extracting the Linux executable.
|
||||||
echo "Built $output"
|
echo "Built $output"
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
#!/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.
|
||||||
|
# dist/linux/pysentry is the default so generated binaries stay outside src/.
|
||||||
output="${1:-dist/linux/pysentry}"
|
output="${1:-dist/linux/pysentry}"
|
||||||
mkdir -p "$(dirname "$output")"
|
mkdir -p "$(dirname "$output")"
|
||||||
|
|
||||||
|
# Fyne needs CGO for its native desktop backend. The script pins the target to
|
||||||
|
# linux/amd64 because this is the first supported Linux artifact; other
|
||||||
|
# architectures can be added later as explicit build targets.
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
export GOOS=linux
|
export GOOS=linux
|
||||||
export GOARCH=amd64
|
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" -o "$output" ./cmd/pysentry
|
||||||
|
|
||||||
|
# The application icon is embedded by Go, so the Linux build does not need a
|
||||||
|
# sidecar assets directory beside the executable.
|
||||||
echo "Built $output"
|
echo "Built $output"
|
||||||
|
|||||||
@@ -1,27 +1,46 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
REM Optional first argument allows CI or a developer to choose another output
|
||||||
|
REM path. The default keeps all generated binaries under dist\ so the source tree
|
||||||
|
REM stays clean and the old bin\ folder is no longer needed.
|
||||||
set "OUTPUT=%~1"
|
set "OUTPUT=%~1"
|
||||||
if "%OUTPUT%"=="" set "OUTPUT=dist\windows\pysentry.exe"
|
if "%OUTPUT%"=="" set "OUTPUT=dist\windows\pysentry.exe"
|
||||||
|
|
||||||
|
REM Prefer the standard Go installer path on Windows, but fall back to PATH for
|
||||||
|
REM machines where Go was installed by another package manager.
|
||||||
set "GOEXE=%ProgramFiles%\Go\bin\go.exe"
|
set "GOEXE=%ProgramFiles%\Go\bin\go.exe"
|
||||||
if not exist "%GOEXE%" set "GOEXE=go"
|
if not exist "%GOEXE%" set "GOEXE=go"
|
||||||
|
|
||||||
|
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
|
||||||
|
REM expected by the Windows build; prepending it keeps the script self-contained
|
||||||
|
REM without permanently changing the user's system PATH.
|
||||||
if exist "C:\msys64\ucrt64\bin" set "PATH=C:\msys64\ucrt64\bin;%PATH%"
|
if exist "C:\msys64\ucrt64\bin" set "PATH=C:\msys64\ucrt64\bin;%PATH%"
|
||||||
|
|
||||||
|
REM Build a 64-bit Windows binary. CGO must stay enabled for Fyne; disabling it
|
||||||
|
REM would make the native GUI backend fail to compile.
|
||||||
set "CGO_ENABLED=1"
|
set "CGO_ENABLED=1"
|
||||||
set "GOOS=windows"
|
set "GOOS=windows"
|
||||||
set "GOARCH=amd64"
|
set "GOARCH=amd64"
|
||||||
|
|
||||||
|
REM Create the target directory before invoking Go so custom output paths work.
|
||||||
for %%I in ("%OUTPUT%") do set "OUTDIR=%%~dpI"
|
for %%I in ("%OUTPUT%") do set "OUTDIR=%%~dpI"
|
||||||
if not exist "%OUTDIR%" mkdir "%OUTDIR%"
|
if not exist "%OUTDIR%" mkdir "%OUTDIR%"
|
||||||
|
|
||||||
|
REM windres embeds the .ico file into the PE executable so Windows Explorer,
|
||||||
|
REM shortcuts, and the taskbar can show the PySentry icon. The Go embed package
|
||||||
|
REM handles Fyne's runtime icon, but Explorer reads this Windows resource instead.
|
||||||
where windres.exe >nul 2>nul
|
where windres.exe >nul 2>nul
|
||||||
if %ERRORLEVEL%==0 (
|
if %ERRORLEVEL%==0 (
|
||||||
windres.exe -O coff -o cmd\pysentry\rsrc_windows_amd64.syso packaging\windows\pysentry.rc
|
windres.exe -O coff -o cmd\pysentry\rsrc_windows_amd64.syso packaging\windows\pysentry.rc
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REM -trimpath removes local machine paths from the binary, -s -w reduce binary
|
||||||
|
REM size, and -H=windowsgui prevents a separate console window from opening when
|
||||||
|
REM the GUI app starts from Explorer or a shortcut.
|
||||||
"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui" -o "%OUTPUT%" .\cmd\pysentry
|
"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui" -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 to the binary. Runtime YAML and log files are created by the app itself.
|
||||||
echo Built %OUTPUT%
|
echo Built %OUTPUT%
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package core
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// Config is stored in pysentry.yaml next to the program. It contains only
|
||||||
|
// application-level choices: where to read jobs from, where to write logs, and
|
||||||
|
// how the desktop shell should behave.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
JobsDir string `yaml:"jobs_dir"`
|
JobsDir string `yaml:"jobs_dir"`
|
||||||
LogsDir string `yaml:"logs_dir"`
|
LogsDir string `yaml:"logs_dir"`
|
||||||
@@ -11,10 +14,17 @@ type Config struct {
|
|||||||
NotifyOnFailure bool `yaml:"notify_on_failure"`
|
NotifyOnFailure bool `yaml:"notify_on_failure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
|
||||||
|
// object leaves room for future metadata without breaking the basic file format.
|
||||||
type JobsFile struct {
|
type JobsFile struct {
|
||||||
Jobs []Job `yaml:"jobs"`
|
Jobs []Job `yaml:"jobs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Job is the user-visible scheduled command.
|
||||||
|
//
|
||||||
|
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
|
||||||
|
// while PySentry is running, but writing them to jobs.yaml would make the jobs
|
||||||
|
// file noisy and would mix durable configuration with transient execution state.
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID int `yaml:"id"`
|
ID int `yaml:"id"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
@@ -28,9 +38,15 @@ type Job struct {
|
|||||||
Logs []RunRecord `yaml:"-"`
|
Logs []RunRecord `yaml:"-"`
|
||||||
Output string `yaml:"-"`
|
Output string `yaml:"-"`
|
||||||
|
|
||||||
|
// nextDue is kept as time.Time for scheduler comparisons. The formatted
|
||||||
|
// NextRun string above exists only for display in the GUI and YAML rewriting
|
||||||
|
// must not persist it.
|
||||||
nextDue time.Time
|
nextDue time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunRecord represents one visible activity item. Scheduled and manual command
|
||||||
|
// output is also written to a log file; the in-memory Output copy exists so the
|
||||||
|
// latest run can be displayed without reopening the log on every repaint.
|
||||||
type RunRecord struct {
|
type RunRecord struct {
|
||||||
Time string `yaml:"time"`
|
Time string `yaml:"time"`
|
||||||
JobID int `yaml:"job_id"`
|
JobID int `yaml:"job_id"`
|
||||||
|
|||||||
@@ -6,10 +6,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// The config file stays beside the executable so the portable build behaves
|
||||||
|
// predictably: moving the program folder moves its settings with it.
|
||||||
ConfigFileName = "pysentry.yaml"
|
ConfigFileName = "pysentry.yaml"
|
||||||
|
// Jobs are kept in a separate YAML file because the user can choose a
|
||||||
|
// different jobs directory, while application settings remain local to the
|
||||||
|
// installed/copied program.
|
||||||
JobsFileName = "jobs.yaml"
|
JobsFileName = "jobs.yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Paths contains both the physical program location and the resolved runtime
|
||||||
|
// storage locations. Keeping resolved paths in one struct prevents the GUI and
|
||||||
|
// scheduler from interpreting relative directories differently.
|
||||||
type Paths struct {
|
type Paths struct {
|
||||||
AppDir string
|
AppDir string
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
@@ -19,6 +27,10 @@ type Paths struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResolvePaths() (Paths, error) {
|
func ResolvePaths() (Paths, error) {
|
||||||
|
// os.Executable is used instead of the current working directory because GUI
|
||||||
|
// apps are often launched from Explorer, a tray shortcut, or a desktop file.
|
||||||
|
// In those cases the working directory can be surprising, but the executable
|
||||||
|
// path is stable and matches the "portable app folder" storage model.
|
||||||
executable, err := os.Executable()
|
executable, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Paths{}, err
|
return Paths{}, err
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ const commandTimeout = 30 * time.Second
|
|||||||
|
|
||||||
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
|
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
|
// Commands can hang forever if a script waits for input or a child process
|
||||||
|
// stalls. A fixed timeout is a conservative first guardrail for a desktop
|
||||||
|
// scheduler; later it can become a per-job setting without changing the
|
||||||
|
// runner contract.
|
||||||
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// The command is executed through the platform shell so users can type the
|
||||||
|
// same command they would test manually in cmd.exe or sh. This is less strict
|
||||||
|
// than argv-based execution, but it is the expected behavior for a cron-like
|
||||||
|
// tool that supports redirection, environment expansion, and shell builtins.
|
||||||
command := shellCommand(runCtx, job.Command)
|
command := shellCommand(runCtx, job.Command)
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -59,6 +67,9 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe
|
|||||||
LogFile: logFile,
|
LogFile: logFile,
|
||||||
Output: output,
|
Output: output,
|
||||||
}
|
}
|
||||||
|
// Keep a small in-memory history for the currently running GUI. Full command
|
||||||
|
// output is persisted to files, so retaining every past record in RAM would
|
||||||
|
// only duplicate data and make long sessions grow without bound.
|
||||||
job.Logs = append([]RunRecord{record}, job.Logs...)
|
job.Logs = append([]RunRecord{record}, job.Logs...)
|
||||||
if len(job.Logs) > 50 {
|
if len(job.Logs) > 50 {
|
||||||
job.Logs = job.Logs[:50]
|
job.Logs = job.Logs[:50]
|
||||||
@@ -82,6 +93,9 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
|||||||
var logs []logFile
|
var logs []logFile
|
||||||
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
// Only PySentry run logs are managed here. Directories and non-.log files
|
||||||
|
// are intentionally ignored so the user can keep notes or other artifacts
|
||||||
|
// in the same folder without the cleanup policy deleting them.
|
||||||
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
|
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -91,6 +105,8 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
|
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
|
||||||
|
// Cleanup is best-effort: failing to delete one file should not block
|
||||||
|
// the scheduler from running future jobs.
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -101,6 +117,9 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
sort.Slice(logs, func(i int, j int) bool {
|
sort.Slice(logs, func(i int, j int) bool {
|
||||||
|
// Newest files are kept first, then everything after maxFiles is removed.
|
||||||
|
// This matches the user's expectation that the most recent failures and
|
||||||
|
// command output remain available for investigation.
|
||||||
return logs[i].modTime.After(logs[j].modTime)
|
return logs[i].modTime.After(logs[j].modTime)
|
||||||
})
|
})
|
||||||
for _, old := range logs[maxFiles:] {
|
for _, old := range logs[maxFiles:] {
|
||||||
@@ -116,6 +135,9 @@ func writeRunLog(logsDir string, job Job, trigger string, state string, detail s
|
|||||||
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
// The timestamp comes first so a plain directory listing is naturally sorted
|
||||||
|
// by run time. The job name is included for human scanning, but sanitized to
|
||||||
|
// avoid characters that are invalid on Windows or awkward on shells.
|
||||||
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
||||||
path := filepath.Join(logsDir, fileName)
|
path := filepath.Join(logsDir, fileName)
|
||||||
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\n\n%s\n",
|
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\n\n%s\n",
|
||||||
@@ -151,8 +173,12 @@ func sanitizeFileName(name string) string {
|
|||||||
|
|
||||||
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
|
// cmd.exe /C preserves Windows users' expectations for commands such as
|
||||||
|
// "dir", "copy", variable expansion, and .bat/.cmd wrappers.
|
||||||
return exec.CommandContext(ctx, "cmd.exe", "/C", command)
|
return exec.CommandContext(ctx, "cmd.exe", "/C", command)
|
||||||
}
|
}
|
||||||
|
// sh -c is the portable baseline for Linux builds. It keeps the runner small
|
||||||
|
// and avoids a hard dependency on a larger shell such as bash.
|
||||||
return exec.CommandContext(ctx, "sh", "-c", command)
|
return exec.CommandContext(ctx, "sh", "-c", command)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +186,8 @@ func formatOutput(stdout string, stderr string) string {
|
|||||||
stdout = strings.TrimSpace(stdout)
|
stdout = strings.TrimSpace(stdout)
|
||||||
stderr = strings.TrimSpace(stderr)
|
stderr = strings.TrimSpace(stderr)
|
||||||
if stdout == "" {
|
if stdout == "" {
|
||||||
|
// Showing an explicit placeholder is clearer than an empty panel in the
|
||||||
|
// GUI: the user can tell that the command ran but produced no stream data.
|
||||||
stdout = "<empty>"
|
stdout = "<empty>"
|
||||||
}
|
}
|
||||||
if stderr == "" {
|
if stderr == "" {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import (
|
|||||||
|
|
||||||
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||||
|
|
||||||
|
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
|
||||||
|
// It receives a pointer to the jobs slice because the GUI edits the same slice;
|
||||||
|
// this keeps the early architecture simple while storage and scheduling are
|
||||||
|
// still in one desktop process.
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
store *Store
|
store *Store
|
||||||
jobs *[]Job
|
jobs *[]Job
|
||||||
@@ -36,6 +40,10 @@ func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Schedule
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) Start() {
|
func (s *Scheduler) Start() {
|
||||||
|
// A one-second ticker is accurate enough for cron-style desktop automation
|
||||||
|
// and avoids the complexity of maintaining one timer per job. Five-field cron
|
||||||
|
// expressions have minute precision, while @every values may be shorter for
|
||||||
|
// testing and lightweight local tasks.
|
||||||
ticker := time.NewTicker(time.Second)
|
ticker := time.NewTicker(time.Second)
|
||||||
go func() {
|
go func() {
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -60,6 +68,8 @@ func (s *Scheduler) SetPaused(paused bool) {
|
|||||||
|
|
||||||
s.paused = paused
|
s.paused = paused
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
// Pause state is reflected into each job's display string so the list view is
|
||||||
|
// understandable even before the next scheduler tick.
|
||||||
for index := range *s.jobs {
|
for index := range *s.jobs {
|
||||||
job := &(*s.jobs)[index]
|
job := &(*s.jobs)[index]
|
||||||
if !job.Enabled {
|
if !job.Enabled {
|
||||||
@@ -83,6 +93,9 @@ func (s *Scheduler) RunNow(index int) RunRecord {
|
|||||||
return RunRecord{}
|
return RunRecord{}
|
||||||
}
|
}
|
||||||
job := &(*s.jobs)[index]
|
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)
|
record := RunJob(s.ctx, job, "Manual", s.store.Paths.LogsDir)
|
||||||
s.prepareNextRun(job, time.Now())
|
s.prepareNextRun(job, time.Now())
|
||||||
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||||
@@ -120,6 +133,10 @@ func (s *Scheduler) tick(now time.Time) {
|
|||||||
if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
|
if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Run only one due job per tick for now. That avoids overlapping shell
|
||||||
|
// commands in the GUI process and keeps the first version predictable;
|
||||||
|
// a future worker pool can add concurrency once cancellation and status
|
||||||
|
// reporting are more explicit.
|
||||||
record = RunJob(s.ctx, job, "Schedule", s.store.Paths.LogsDir)
|
record = RunJob(s.ctx, job, "Schedule", s.store.Paths.LogsDir)
|
||||||
s.prepareNextRun(job, time.Now())
|
s.prepareNextRun(job, time.Now())
|
||||||
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||||
@@ -166,12 +183,17 @@ func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
|
|||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(schedule, "@every ") {
|
if strings.HasPrefix(schedule, "@every ") {
|
||||||
|
// @every is kept alongside cron because it is convenient for quick tests
|
||||||
|
// and for simple intervals that are awkward to express as five fields.
|
||||||
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
|
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
|
||||||
if err != nil || interval <= 0 {
|
if err != nil || interval <= 0 {
|
||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
return from.Add(interval), true
|
return from.Add(interval), true
|
||||||
}
|
}
|
||||||
|
// Standard five-field cron keeps PySentry compatible with the mental model
|
||||||
|
// users already know from Unix cron, while robfig/cron handles edge cases
|
||||||
|
// such as ranges, steps, and day-of-week names.
|
||||||
parsed, err := cronParser.Parse(schedule)
|
parsed, err := cronParser.Parse(schedule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ func OpenStore() (*Store, []Job, error) {
|
|||||||
}
|
}
|
||||||
store.Config = config
|
store.Config = config
|
||||||
store.applyConfigPaths()
|
store.applyConfigPaths()
|
||||||
|
// Save the config after loading so missing defaults are written back. This
|
||||||
|
// rewrites old or hand-edited files into the current clean schema without
|
||||||
|
// forcing the user to delete them manually.
|
||||||
if err := store.SaveConfig(); err != nil {
|
if err := store.SaveConfig(); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -37,6 +40,9 @@ func OpenStore() (*Store, []Job, error) {
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
normalizeJobs(jobs)
|
normalizeJobs(jobs)
|
||||||
|
// Jobs are also rewritten after normalization. That keeps jobs.yaml compact:
|
||||||
|
// only durable job definitions remain, because runtime fields are tagged
|
||||||
|
// yaml:"-" in the model.
|
||||||
if err := store.SaveJobs(jobs); err != nil {
|
if err := store.SaveJobs(jobs); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -59,6 +65,8 @@ func (s *Store) SaveJobs(jobs []Job) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadOrCreateConfig(paths Paths) (Config, error) {
|
func loadOrCreateConfig(paths Paths) (Config, error) {
|
||||||
|
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||||
|
// executable, while logs are grouped under a dedicated subdirectory.
|
||||||
config := Config{
|
config := Config{
|
||||||
JobsDir: ".",
|
JobsDir: ".",
|
||||||
LogsDir: "logs",
|
LogsDir: "logs",
|
||||||
@@ -80,6 +88,8 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(config.JobsDir) == "" {
|
if strings.TrimSpace(config.JobsDir) == "" {
|
||||||
|
// Empty paths are treated as missing values rather than intentional root
|
||||||
|
// directories. This avoids accidentally writing jobs to unexpected places.
|
||||||
config.JobsDir = "."
|
config.JobsDir = "."
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
@@ -96,6 +106,8 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
|||||||
|
|
||||||
func loadOrCreateJobs(path string) ([]Job, error) {
|
func loadOrCreateJobs(path string) ([]Job, error) {
|
||||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
|
// The first run creates harmless sample jobs so a new user can immediately
|
||||||
|
// see scheduled and manual execution without inventing a command.
|
||||||
jobs := defaultJobs()
|
jobs := defaultJobs()
|
||||||
normalizeJobs(jobs)
|
normalizeJobs(jobs)
|
||||||
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
|
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
|
||||||
@@ -117,6 +129,8 @@ func normalizeJobs(jobs []Job) {
|
|||||||
for index := range jobs {
|
for index := range jobs {
|
||||||
job := &jobs[index]
|
job := &jobs[index]
|
||||||
if job.ID <= 0 {
|
if job.ID <= 0 {
|
||||||
|
// IDs are assigned only when absent. Existing IDs stay stable because
|
||||||
|
// History and future log associations use them to identify jobs.
|
||||||
job.ID = next
|
job.ID = next
|
||||||
}
|
}
|
||||||
if job.ID >= next {
|
if job.ID >= next {
|
||||||
@@ -129,6 +143,8 @@ func normalizeJobs(jobs []Job) {
|
|||||||
job.Schedule = "@every 1m"
|
job.Schedule = "@every 1m"
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(job.Command) == "" {
|
if strings.TrimSpace(job.Command) == "" {
|
||||||
|
// An empty command would fail in a confusing way. A safe echo command
|
||||||
|
// gives the user something observable and harmless instead.
|
||||||
job.Command = echoCommand("PySentry job ran")
|
job.Command = echoCommand("PySentry job ran")
|
||||||
}
|
}
|
||||||
if job.LastRun == "" {
|
if job.LastRun == "" {
|
||||||
@@ -144,6 +160,9 @@ func normalizeJobs(jobs []Job) {
|
|||||||
job.LastState = "Paused"
|
job.LastState = "Paused"
|
||||||
job.NextRun = "Paused"
|
job.NextRun = "Paused"
|
||||||
}
|
}
|
||||||
|
// Runtime fields are reconstructed each time the app starts. Persisted run
|
||||||
|
// records live in log files, not in jobs.yaml, to keep the jobs file easy
|
||||||
|
// to review and edit by hand.
|
||||||
job.Logs = nil
|
job.Logs = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +175,9 @@ func resolveConfiguredDir(appDir string, dir string) string {
|
|||||||
if filepath.IsAbs(dir) {
|
if filepath.IsAbs(dir) {
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
// Relative paths are resolved against the executable directory, not the
|
||||||
|
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
||||||
|
// Explorer launches, and terminal launches consistent.
|
||||||
return filepath.Clean(filepath.Join(appDir, dir))
|
return filepath.Clean(filepath.Join(appDir, dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +195,9 @@ func writeYAML(path string, value any) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// WriteFile replaces the full file instead of patching it in place. For small
|
||||||
|
// YAML files this is simpler and prevents stale keys from older versions from
|
||||||
|
// lingering after the schema changes.
|
||||||
return os.WriteFile(path, data, 0o644)
|
return os.WriteFile(path, data, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,5 +233,7 @@ func echoCommand(message string) string {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return "echo " + message
|
return "echo " + message
|
||||||
}
|
}
|
||||||
|
// POSIX shells need quotes for messages with spaces. Single quotes inside the
|
||||||
|
// message are escaped using the standard close-quote/backslash/reopen pattern.
|
||||||
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,15 @@ const appID = "io.github.pysentry.desktop"
|
|||||||
const allFolders = "All"
|
const allFolders = "All"
|
||||||
const noFolder = "No folder"
|
const noFolder = "No folder"
|
||||||
|
|
||||||
|
// The GUI package aliases core types to keep widget callbacks short. The actual
|
||||||
|
// durable model still lives in src/core, so GUI code does not define a second
|
||||||
|
// copy of the scheduler data.
|
||||||
type job = core.Job
|
type job = core.Job
|
||||||
type event = core.RunRecord
|
type event = core.RunRecord
|
||||||
|
|
||||||
func Run() {
|
func Run() {
|
||||||
|
// A stable app ID lets Fyne persist desktop preferences consistently across
|
||||||
|
// launches and gives tray/window integration a predictable identity.
|
||||||
a := app.NewWithID(appID)
|
a := app.NewWithID(appID)
|
||||||
a.SetIcon(loadAppIcon())
|
a.SetIcon(loadAppIcon())
|
||||||
|
|
||||||
@@ -44,6 +49,8 @@ func loadAppIcon() fyne.Resource {
|
|||||||
func configureSystemTray(a fyne.App, w fyne.Window) {
|
func configureSystemTray(a fyne.App, w fyne.Window) {
|
||||||
desk, ok := a.(desktop.App)
|
desk, ok := a.(desktop.App)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
// Not every Fyne driver exposes desktop tray features. Returning silently
|
||||||
|
// keeps the same binary usable on platforms or sessions without a tray.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +66,9 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
|
|||||||
)
|
)
|
||||||
desk.SetSystemTrayMenu(menu)
|
desk.SetSystemTrayMenu(menu)
|
||||||
w.SetCloseIntercept(func() {
|
w.SetCloseIntercept(func() {
|
||||||
|
// Closing hides the window instead of quitting because scheduler tools are
|
||||||
|
// expected to keep working in the background. The explicit Quit tray item
|
||||||
|
// remains the way to stop the process.
|
||||||
w.Hide()
|
w.Hide()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -70,6 +80,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
}
|
}
|
||||||
events := collectActivity(jobs)
|
events := collectActivity(jobs)
|
||||||
|
|
||||||
|
// The GUI keeps the loaded jobs slice in memory and persists changes after
|
||||||
|
// each edit/run. This keeps the first version responsive and easy to reason
|
||||||
|
// about; a database would be unnecessary overhead for one YAML file.
|
||||||
nextJobID := nextID(jobs)
|
nextJobID := nextID(jobs)
|
||||||
selected := 0
|
selected := 0
|
||||||
selectedFolder := allFolders
|
selectedFolder := allFolders
|
||||||
@@ -86,6 +99,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
commandOutput := widget.NewTextGrid()
|
commandOutput := widget.NewTextGrid()
|
||||||
commandOutput.SetText(jobs[selected].Output)
|
commandOutput.SetText(jobs[selected].Output)
|
||||||
commandOutputScroll := container.NewScroll(commandOutput)
|
commandOutputScroll := container.NewScroll(commandOutput)
|
||||||
|
// Command output can contain long lines and preserved whitespace. TextGrid is
|
||||||
|
// used instead of Label so stdout/stderr remains readable and does not vanish
|
||||||
|
// against the theme when it is placed inside a scroll container.
|
||||||
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
|
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
|
||||||
history := newHistoryView(&events)
|
history := newHistoryView(&events)
|
||||||
jobLogs := widget.NewList(
|
jobLogs := widget.NewList(
|
||||||
@@ -103,6 +119,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
|
|
||||||
updateDetails := func(index int) {
|
updateDetails := func(index int) {
|
||||||
if index < 0 || index >= len(jobs) {
|
if index < 0 || index >= len(jobs) {
|
||||||
|
// A folder filter can temporarily leave no selectable rows. Clearing
|
||||||
|
// the details panel avoids showing stale information for a hidden job.
|
||||||
title.SetText("No job selected")
|
title.SetText("No job selected")
|
||||||
folder.SetText("")
|
folder.SetText("")
|
||||||
schedule.SetText("")
|
schedule.SetText("")
|
||||||
@@ -125,6 +143,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
commandOutput.SetText(current.Output)
|
commandOutput.SetText(current.Output)
|
||||||
}
|
}
|
||||||
refresh := func() {
|
refresh := func() {
|
||||||
|
// Several callbacks mutate jobs, filters, and event history. A single
|
||||||
|
// refresh closure keeps the different widgets synchronized after each
|
||||||
|
// mutation without introducing a heavier state-management layer.
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||||
updateDetails(selected)
|
updateDetails(selected)
|
||||||
jobLogs.Refresh()
|
jobLogs.Refresh()
|
||||||
@@ -148,6 +169,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
|
|
||||||
current := jobs[filteredJobs[id]]
|
current := jobs[filteredJobs[id]]
|
||||||
name.SetText(current.Name)
|
name.SetText(current.Name)
|
||||||
|
// Keep each row compact: folder, schedule, and command are shown in one
|
||||||
|
// metadata line so the left pane stays useful even with many jobs.
|
||||||
meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + current.Command)
|
meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + current.Command)
|
||||||
status.SetText(statusText(current))
|
status.SetText(statusText(current))
|
||||||
},
|
},
|
||||||
@@ -169,6 +192,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||||
list.Refresh()
|
list.Refresh()
|
||||||
if len(filteredJobs) == 0 {
|
if len(filteredJobs) == 0 {
|
||||||
|
// The "No folder" filter is intentionally allowed to be empty. It is a
|
||||||
|
// real filter choice, not an error state, so the selection is cleared.
|
||||||
selected = -1
|
selected = -1
|
||||||
updateDetails(-1)
|
updateDetails(-1)
|
||||||
return
|
return
|
||||||
@@ -186,6 +211,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
jobs = append(jobs, saved)
|
jobs = append(jobs, saved)
|
||||||
selected = len(jobs) - 1
|
selected = len(jobs) - 1
|
||||||
created := newEvent(saved.ID, saved.Name, "Created", "Job was added")
|
created := newEvent(saved.ID, saved.Name, "Created", "Job was added")
|
||||||
|
// UI events are kept in memory for the current session. They explain
|
||||||
|
// user actions in History, while command output remains in log files.
|
||||||
jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...)
|
jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...)
|
||||||
events = append([]event{created}, events...)
|
events = append([]event{created}, events...)
|
||||||
_ = store.SaveJobs(jobs)
|
_ = store.SaveJobs(jobs)
|
||||||
@@ -229,6 +256,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if schedulerPaused {
|
if schedulerPaused {
|
||||||
|
// The global pause is treated as an emergency stop for all execution,
|
||||||
|
// including manual "Run now", so the user has one reliable switch.
|
||||||
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -262,6 +291,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
stopAllButton.SetIcon(theme.MediaStopIcon())
|
stopAllButton.SetIcon(theme.MediaStopIcon())
|
||||||
for index := range jobs {
|
for index := range jobs {
|
||||||
if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" {
|
if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" {
|
||||||
|
// The scheduler will calculate the exact next run when it is
|
||||||
|
// resumed; this interim text prevents a stale paused timestamp.
|
||||||
jobs[index].NextRun = "Waiting for scheduler"
|
jobs[index].NextRun = "Waiting for scheduler"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,6 +338,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
deleted := jobs[selected]
|
deleted := jobs[selected]
|
||||||
|
// Deletion is confirmed because jobs can represent real system actions.
|
||||||
|
// There is no undo yet, so accidental removal should require one more click.
|
||||||
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
|
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
|
||||||
if !confirm {
|
if !confirm {
|
||||||
return
|
return
|
||||||
@@ -358,6 +391,8 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) {
|
scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) {
|
||||||
|
// Scheduled runs happen on the scheduler goroutine. The callback updates
|
||||||
|
// the shared in-memory event list so History reflects background activity.
|
||||||
events = append([]event{record}, events...)
|
events = append([]event{record}, events...)
|
||||||
refresh()
|
refresh()
|
||||||
})
|
})
|
||||||
@@ -381,6 +416,8 @@ func statusText(j job) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newEvent(jobID int, jobName string, state string, detail string) event {
|
func newEvent(jobID int, jobName string, state string, detail string) event {
|
||||||
|
// UI events use a short time because they are session-local activity markers.
|
||||||
|
// Command runs use full timestamps from core.RunJob and have log files.
|
||||||
return event{
|
return event{
|
||||||
Time: time.Now().Format("15:04:05"),
|
Time: time.Now().Format("15:04:05"),
|
||||||
JobID: jobID,
|
JobID: jobID,
|
||||||
@@ -405,6 +442,9 @@ func eventText(e event) string {
|
|||||||
func collectActivity(jobs []job) []event {
|
func collectActivity(jobs []job) []event {
|
||||||
var events []event
|
var events []event
|
||||||
for _, current := range jobs {
|
for _, current := range jobs {
|
||||||
|
// At startup this is usually empty because jobs.yaml does not persist
|
||||||
|
// runtime logs. The function still centralizes the merge for future
|
||||||
|
// history loading from log metadata.
|
||||||
events = append(events, current.Logs...)
|
events = append(events, current.Logs...)
|
||||||
}
|
}
|
||||||
return events
|
return events
|
||||||
@@ -437,6 +477,8 @@ func filteredJobIndexes(jobs []job, folder string) []int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func folderOptions(jobs []job) []string {
|
func folderOptions(jobs []job) []string {
|
||||||
|
// "All" and "No folder" are always present so the filter UI is stable even
|
||||||
|
// before the user creates folders.
|
||||||
options := []string{allFolders, noFolder}
|
options := []string{allFolders, noFolder}
|
||||||
seen := map[string]bool{allFolders: true, noFolder: true}
|
seen := map[string]bool{allFolders: true, noFolder: true}
|
||||||
for _, current := range jobs {
|
for _, current := range jobs {
|
||||||
@@ -505,6 +547,8 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(schedule.Text) == "" || strings.TrimSpace(command.Text) == "" {
|
if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(schedule.Text) == "" || strings.TrimSpace(command.Text) == "" {
|
||||||
|
// These three fields are the minimum executable job definition.
|
||||||
|
// Folder is optional because ungrouped jobs are a supported workflow.
|
||||||
dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w)
|
dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -594,10 +638,15 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
|
|||||||
settingsStatus.SetText("Save failed: " + err.Error())
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// When the jobs directory changes, save the currently loaded jobs to the
|
||||||
|
// newly resolved path immediately. That makes the setting visible on disk
|
||||||
|
// without requiring a restart or a separate migration command.
|
||||||
if err := store.SaveJobs(*jobs); err != nil {
|
if err := store.SaveJobs(*jobs); err != nil {
|
||||||
settingsStatus.SetText("Jobs save failed: " + err.Error())
|
settingsStatus.SetText("Jobs save failed: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Cleanup runs on settings save so a user who tightens retention limits
|
||||||
|
// sees the new policy take effect right away.
|
||||||
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
|
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
|
||||||
settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
|
settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
|
||||||
return
|
return
|
||||||
@@ -631,6 +680,8 @@ func chooseFolder(w fyne.Window, target *widget.Entry) {
|
|||||||
}
|
}
|
||||||
target.SetText(uri.Path())
|
target.SetText(uri.Path())
|
||||||
}, w)
|
}, w)
|
||||||
|
// The default folder picker can be cramped on Windows. A larger size makes
|
||||||
|
// long paths readable and avoids forcing the user to resize it every time.
|
||||||
folderDialog.Resize(fyne.NewSize(900, 640))
|
folderDialog.Resize(fyne.NewSize(900, 640))
|
||||||
folderDialog.Show()
|
folderDialog.Show()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user