From ddabfd2da2dc36ffb850cfd02111c5716d90c743 Mon Sep 17 00:00:00 2001 From: mixeme Date: Mon, 15 Jun 2026 08:23:24 +0300 Subject: [PATCH] Bump version and add changelog --- CHANGELOG.md | 24 ++++++ README.md | 36 ++++---- src/core/autostart_linux.go | 128 +++++++++++++++++++---------- src/core/autostart_windows.go | 32 +++++++- src/core/autostart_windows_test.go | 25 ++++++ src/core/version.go | 2 +- src/gui/app.go | 6 +- 7 files changed, 184 insertions(+), 69 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/core/autostart_windows_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f74cbda --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable PySentry changes are recorded in this file. + +## 0.2.0 - 2026-06-15 + +- Added working autostart support with status diagnostics in Settings. +- Switched Linux autostart to XDG Autostart `.desktop` files and clean up the legacy user systemd unit. +- Fixed Windows autostart status detection by parsing `HKCU Run` values and comparing executable paths reliably. +- Added background job execution so the GUI does not block while commands run. +- Suppressed Windows console windows for scheduled and manual command runs. +- Added application version display in the window title, Settings, and build artifact names. +- Moved release artifact commands from `Dockerfile` into `scripts/build-release-linux.sh` with interactive target selection. +- Added release build targets for Linux amd64, Linux arm64, and Windows amd64. +- Added README dependency installation notes and official Go/Fyne links. + +## 0.1.0 - 2026-06-14 + +- Added the initial Fyne desktop GUI. +- Added YAML settings and single-file YAML job storage. +- Added `@every` and standard 5-field cron schedules. +- Added manual and scheduled command runs with per-run log files. +- Added job folders, history, global pause, and Windows tray support. +- Added Windows and Linux build helpers. diff --git a/README.md b/README.md index ca955ee..4879207 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The binary is written to: ```text # GUI executable produced by scripts\build-windows.bat. -dist\windows\pysentry-0.1.0-windows-amd64.exe +dist\windows\pysentry-0.2.0-windows-amd64.exe ``` Linux: @@ -95,7 +95,7 @@ The binary is written to: ```text # Linux executable produced by scripts/build-linux.sh. -dist/linux/pysentry-0.1.0-linux-amd64 +dist/linux/pysentry-0.2.0-linux-amd64 ``` Linux using Docker: @@ -112,7 +112,7 @@ The binary is copied to: ```text # Linux executable copied out of the Docker build image. -dist\linux\pysentry-0.1.0-linux-amd64 +dist\linux\pysentry-0.2.0-linux-amd64 ``` Release build from Linux: @@ -136,13 +136,13 @@ The binaries are copied to: ```text # Linux artifact. -dist/linux/pysentry-0.1.0-linux-amd64 +dist/linux/pysentry-0.2.0-linux-amd64 # Linux arm64 artifact. -dist/linux/pysentry-0.1.0-linux-arm64 +dist/linux/pysentry-0.2.0-linux-arm64 # Windows artifact cross-compiled from Linux. -dist/windows/pysentry-0.1.0-windows-amd64.exe +dist/windows/pysentry-0.2.0-windows-amd64.exe ``` ## Run From Source @@ -275,19 +275,18 @@ PySentry is a user desktop application, not a system daemon, so autostart should Linux: ```ini -# PySentry writes a systemd user unit and enables it with -# systemctl --user enable --now pysentry.service when Start on login is enabled. -# A user unit starts after login and can run the tray/GUI app in the user's -# desktop session. -[Unit] -Description=PySentry desktop scheduler +# PySentry writes an XDG Autostart desktop entry when Start on login is enabled. +# This is better for a GUI/tray application than a systemd user service because +# the desktop environment starts it inside the graphical user session. +# Saving the setting also removes the old ~/.config/systemd/user/pysentry.service +# unit if it was created by an earlier PySentry build. +~/.config/autostart/pysentry.desktop -[Service] -ExecStart=/opt/pysentry/pysentry-0.1.0-linux-amd64 -Restart=on-failure - -[Install] -WantedBy=default.target +[Desktop Entry] +Type=Application +Name=PySentry +Exec=/opt/pysentry/pysentry-0.2.0-linux-amd64 +Terminal=false ``` Windows: @@ -308,6 +307,7 @@ HKCU\Software\Microsoft\Windows\CurrentVersion\Run\PySentry - `src/core` contains YAML storage, command execution, scheduling, and log cleanup. - `assets` contains app icons that are embedded into the application binary. - `scripts` contains build helpers. +- `CHANGELOG.md` records release notes and version history. Build outputs are written to `dist/`. The old local `bin/` directory is not used. diff --git a/src/core/autostart_linux.go b/src/core/autostart_linux.go index 3870353..0b26287 100644 --- a/src/core/autostart_linux.go +++ b/src/core/autostart_linux.go @@ -7,76 +7,68 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" ) -const autostartUnitName = "pysentry.service" +const autostartDesktopFileName = "pysentry.desktop" func SetAutostart(enabled bool, executablePath string) error { - unitDir, err := userSystemdDir() + desktopPath, err := autostartDesktopPath() 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) { + if err := cleanupLegacySystemdAutostart(); err != nil { return err } - return exec.Command("systemctl", "--user", "daemon-reload").Run() + + if enabled { + if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil { + return err + } + desktopFile := fmt.Sprintf(`[Desktop Entry] +Type=Application +Name=PySentry +Comment=PySentry desktop scheduler +Exec=%s +Terminal=false +X-GNOME-Autostart-enabled=true +`, quoteDesktopExec(executablePath)) + return os.WriteFile(desktopPath, []byte(desktopFile), 0o644) + } + + if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil } func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) { - unitDir, err := userSystemdDir() + desktopPath, err := autostartDesktopPath() if err != nil { - return false, "Cannot resolve user systemd directory" + return false, "Cannot resolve XDG autostart directory" } - unitPath := filepath.Join(unitDir, autostartUnitName) - data, readErr := os.ReadFile(unitPath) - enabledErr := exec.Command("systemctl", "--user", "is-enabled", "--quiet", autostartUnitName).Run() + if legacySystemdAutostartExists() { + return false, "Legacy systemd autostart entry still exists" + } + data, readErr := os.ReadFile(desktopPath) if !expectedEnabled { - if os.IsNotExist(readErr) && enabledErr != nil { + if os.IsNotExist(readErr) { return true, "Autostart is off" } - return false, "Autostart unit exists while setting is off" + return false, "Autostart desktop entry exists while setting is off" } if readErr != nil { - return false, "Autostart unit is missing" + return false, "Autostart desktop entry 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" + if !strings.Contains(string(data), "Exec="+quoteDesktopExec(executablePath)) { + return false, "Autostart desktop entry points to another executable" } return true, "Autostart is configured" } -func userSystemdDir() (string, error) { +func autostartDesktopPath() (string, error) { configHome := os.Getenv("XDG_CONFIG_HOME") if configHome == "" { home, err := os.UserHomeDir() @@ -85,5 +77,51 @@ func userSystemdDir() (string, error) { } configHome = filepath.Join(home, ".config") } - return filepath.Join(configHome, "systemd", "user"), nil + return filepath.Join(configHome, "autostart", autostartDesktopFileName), nil +} + +func quoteDesktopExec(path string) string { + return strconv.Quote(path) +} + +func cleanupLegacySystemdAutostart() error { + unitPath, err := legacySystemdUnitPath() + if err != nil { + return err + } + if _, err := os.Stat(unitPath); os.IsNotExist(err) { + return nil + } + + // Older PySentry builds used a systemd user unit for autostart. The current + // Linux implementation uses XDG Autostart because PySentry is a GUI/tray + // application and should be launched by the desktop session. Disable and + // remove the old unit so the two mechanisms do not fight or start duplicates. + _ = exec.Command("systemctl", "--user", "disable", "pysentry.service").Run() + if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) { + return err + } + _ = exec.Command("systemctl", "--user", "daemon-reload").Run() + return nil +} + +func legacySystemdAutostartExists() bool { + unitPath, err := legacySystemdUnitPath() + if err != nil { + return false + } + _, err = os.Stat(unitPath) + return err == nil +} + +func legacySystemdUnitPath() (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", "pysentry.service"), nil } diff --git a/src/core/autostart_windows.go b/src/core/autostart_windows.go index cf265c9..6a82dfe 100644 --- a/src/core/autostart_windows.go +++ b/src/core/autostart_windows.go @@ -1,8 +1,9 @@ package core import ( - "fmt" "os/exec" + "path/filepath" + "strconv" "strings" ) @@ -17,7 +18,7 @@ func SetAutostart(enabled bool, executablePath string) error { 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") + command := exec.Command("reg.exe", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/t", "REG_SZ", "/d", strconv.Quote(executablePath), "/f") configureHiddenWindow(command) return command.Run() } @@ -41,9 +42,32 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) return false, "Autostart entry is missing" } - text := strings.ReplaceAll(string(output), `"`, "") - if !strings.Contains(text, executablePath) { + actual, ok := parseRegistryRunValue(string(output)) + if !ok { + return false, "Autostart entry cannot be read" + } + if !sameWindowsPath(actual, executablePath) { return false, "Autostart points to another executable" } return true, "Autostart is configured" } + +func parseRegistryRunValue(output string) (string, bool) { + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + for index, field := range fields { + if field == "REG_SZ" && index+1 < len(fields) { + value := strings.Join(fields[index+1:], " ") + value = strings.Trim(value, `"`) + return value, value != "" + } + } + } + return "", false +} + +func sameWindowsPath(left string, right string) bool { + left = filepath.Clean(strings.Trim(left, `"`)) + right = filepath.Clean(strings.Trim(right, `"`)) + return strings.EqualFold(left, right) +} diff --git a/src/core/autostart_windows_test.go b/src/core/autostart_windows_test.go new file mode 100644 index 0000000..7276446 --- /dev/null +++ b/src/core/autostart_windows_test.go @@ -0,0 +1,25 @@ +//go:build windows + +package core + +import "testing" + +func TestParseRegistryRunValue(t *testing.T) { + output := ` +HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run + PySentry REG_SZ "D:\Apps\PySentry\pysentry.exe" +` + value, ok := parseRegistryRunValue(output) + if !ok { + t.Fatal("expected registry value to parse") + } + if value != `D:\Apps\PySentry\pysentry.exe` { + t.Fatalf("unexpected value: %q", value) + } +} + +func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) { + if !sameWindowsPath(`"D:\Apps\PySentry\pysentry.exe"`, `d:\apps\pysentry\pysentry.exe`) { + t.Fatal("expected paths to match") + } +} diff --git a/src/core/version.go b/src/core/version.go index 02445bc..f225cf8 100644 --- a/src/core/version.go +++ b/src/core/version.go @@ -3,4 +3,4 @@ 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" +var Version = "0.2.0" diff --git a/src/gui/app.go b/src/gui/app.go index a4149f8..1f57ae1 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -591,7 +591,7 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje startOnLogin.SetChecked(store.Config.StartOnLogin) autostartStatus := widget.NewLabel("") refreshAutostartStatus := func() { - ok, message := core.AutostartStatus(startOnLogin.Checked, store.Paths.ExecutablePath) + ok, message := core.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath) if ok { autostartStatus.SetText("OK: " + message) return @@ -599,6 +599,10 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje autostartStatus.SetText("Problem: " + message) } startOnLogin.OnChanged = func(bool) { + if startOnLogin.Checked != store.Config.StartOnLogin { + autostartStatus.SetText("Pending: save settings to apply") + return + } refreshAutostartStatus() } refreshAutostartStatus()