Bump version and add changelog
This commit is contained in:
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user