Bump version and add changelog

This commit is contained in:
mixeme
2026-06-15 08:23:24 +03:00
parent 91080a7a9d
commit ddabfd2da2
7 changed files with 184 additions and 69 deletions
+24
View File
@@ -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.
+18 -18
View File
@@ -80,7 +80,7 @@ The binary is written to:
```text ```text
# GUI executable produced by scripts\build-windows.bat. # 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: Linux:
@@ -95,7 +95,7 @@ The binary is written to:
```text ```text
# Linux executable produced by scripts/build-linux.sh. # 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: Linux using Docker:
@@ -112,7 +112,7 @@ The binary is copied to:
```text ```text
# Linux executable copied out of the Docker build image. # 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: Release build from Linux:
@@ -136,13 +136,13 @@ The binaries are copied to:
```text ```text
# Linux artifact. # Linux artifact.
dist/linux/pysentry-0.1.0-linux-amd64 dist/linux/pysentry-0.2.0-linux-amd64
# Linux arm64 artifact. # 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. # 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 ## Run From Source
@@ -275,19 +275,18 @@ PySentry is a user desktop application, not a system daemon, so autostart should
Linux: Linux:
```ini ```ini
# PySentry writes a systemd user unit and enables it with # PySentry writes an XDG Autostart desktop entry when Start on login is enabled.
# systemctl --user enable --now pysentry.service when Start on login is enabled. # This is better for a GUI/tray application than a systemd user service because
# A user unit starts after login and can run the tray/GUI app in the user's # the desktop environment starts it inside the graphical user session.
# desktop session. # Saving the setting also removes the old ~/.config/systemd/user/pysentry.service
[Unit] # unit if it was created by an earlier PySentry build.
Description=PySentry desktop scheduler ~/.config/autostart/pysentry.desktop
[Service] [Desktop Entry]
ExecStart=/opt/pysentry/pysentry-0.1.0-linux-amd64 Type=Application
Restart=on-failure Name=PySentry
Exec=/opt/pysentry/pysentry-0.2.0-linux-amd64
[Install] Terminal=false
WantedBy=default.target
``` ```
Windows: Windows:
@@ -308,6 +307,7 @@ HKCU\Software\Microsoft\Windows\CurrentVersion\Run\PySentry
- `src/core` contains YAML storage, command execution, scheduling, and log cleanup. - `src/core` contains YAML storage, command execution, scheduling, and log cleanup.
- `assets` contains app icons that are embedded into the application binary. - `assets` contains app icons that are embedded into the application binary.
- `scripts` contains build helpers. - `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. Build outputs are written to `dist/`. The old local `bin/` directory is not used.
+83 -45
View File
@@ -7,76 +7,68 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
) )
const autostartUnitName = "pysentry.service" const autostartDesktopFileName = "pysentry.desktop"
func SetAutostart(enabled bool, executablePath string) error { func SetAutostart(enabled bool, executablePath string) error {
unitDir, err := userSystemdDir() desktopPath, err := autostartDesktopPath()
if err != nil { if err != nil {
return err return err
} }
unitPath := filepath.Join(unitDir, autostartUnitName) if err := cleanupLegacySystemdAutostart(); err != nil {
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 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) { func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
unitDir, err := userSystemdDir() desktopPath, err := autostartDesktopPath()
if err != nil { if err != nil {
return false, "Cannot resolve user systemd directory" return false, "Cannot resolve XDG autostart directory"
} }
unitPath := filepath.Join(unitDir, autostartUnitName) if legacySystemdAutostartExists() {
data, readErr := os.ReadFile(unitPath) return false, "Legacy systemd autostart entry still exists"
enabledErr := exec.Command("systemctl", "--user", "is-enabled", "--quiet", autostartUnitName).Run() }
data, readErr := os.ReadFile(desktopPath)
if !expectedEnabled { if !expectedEnabled {
if os.IsNotExist(readErr) && enabledErr != nil { if os.IsNotExist(readErr) {
return true, "Autostart is off" 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 { if readErr != nil {
return false, "Autostart unit is missing" return false, "Autostart desktop entry is missing"
} }
if !strings.Contains(string(data), executablePath) { if !strings.Contains(string(data), "Exec="+quoteDesktopExec(executablePath)) {
return false, "Autostart unit points to another executable" return false, "Autostart desktop entry points to another executable"
}
if enabledErr != nil {
return false, "Autostart unit is not enabled"
} }
return true, "Autostart is configured" return true, "Autostart is configured"
} }
func userSystemdDir() (string, error) { func autostartDesktopPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME") configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" { if configHome == "" {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
@@ -85,5 +77,51 @@ func userSystemdDir() (string, error) {
} }
configHome = filepath.Join(home, ".config") 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
} }
+28 -4
View File
@@ -1,8 +1,9 @@
package core package core
import ( import (
"fmt"
"os/exec" "os/exec"
"path/filepath"
"strconv"
"strings" "strings"
) )
@@ -17,7 +18,7 @@ func SetAutostart(enabled bool, executablePath string) error {
configureHiddenWindow(deleteCommand) configureHiddenWindow(deleteCommand)
_ = deleteCommand.Run() _ = 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) configureHiddenWindow(command)
return command.Run() return command.Run()
} }
@@ -41,9 +42,32 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
return false, "Autostart entry is missing" return false, "Autostart entry is missing"
} }
text := strings.ReplaceAll(string(output), `"`, "") actual, ok := parseRegistryRunValue(string(output))
if !strings.Contains(text, executablePath) { if !ok {
return false, "Autostart entry cannot be read"
}
if !sameWindowsPath(actual, executablePath) {
return false, "Autostart points to another executable" return false, "Autostart points to another executable"
} }
return true, "Autostart is configured" 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)
}
+25
View File
@@ -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")
}
}
+1 -1
View File
@@ -3,4 +3,4 @@ package core
// Version is the application version shown in the GUI and used by build // 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 // 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. // can override it with Go ldflags when CI tags a build.
var Version = "0.1.0" var Version = "0.2.0"
+5 -1
View File
@@ -591,7 +591,7 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
startOnLogin.SetChecked(store.Config.StartOnLogin) startOnLogin.SetChecked(store.Config.StartOnLogin)
autostartStatus := widget.NewLabel("") autostartStatus := widget.NewLabel("")
refreshAutostartStatus := func() { refreshAutostartStatus := func() {
ok, message := core.AutostartStatus(startOnLogin.Checked, store.Paths.ExecutablePath) ok, message := core.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath)
if ok { if ok {
autostartStatus.SetText("OK: " + message) autostartStatus.SetText("OK: " + message)
return return
@@ -599,6 +599,10 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
autostartStatus.SetText("Problem: " + message) autostartStatus.SetText("Problem: " + message)
} }
startOnLogin.OnChanged = func(bool) { startOnLogin.OnChanged = func(bool) {
if startOnLogin.Checked != store.Config.StartOnLogin {
autostartStatus.SetText("Pending: save settings to apply")
return
}
refreshAutostartStatus() refreshAutostartStatus()
} }
refreshAutostartStatus() refreshAutostartStatus()