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
+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(),