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"
|
||||
Reference in New Issue
Block a user