Add autostart status and release builds
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user