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"