Compare commits

...

3 Commits

Author SHA1 Message Date
mixeme b1fe8bd675 Start autostart launches in tray
Add a shared --start-in-tray argument that lets autostart start the scheduler and tray integration without opening the main window.

Write the argument into Windows Startup shortcuts and Linux XDG Autostart desktop entries, and verify existing autostart entries include it.

Keep manual launches unchanged and let a manual second launch reveal an already-running instance while duplicate autostart launches stay hidden.
2026-06-16 21:52:00 +03:00
mixeme 44f24ab3d8 Use Startup shortcut for Windows autostart
Replace the HKCU Run autostart entry with a per-user Startup folder shortcut. A .lnk stores TargetPath separately, which avoids fragile quoting when the executable path contains spaces.

Remove legacy PySentry and GoSentry Run entries when saving autostart settings, and report shortcut status from the actual shortcut target.

Add Windows tests that create and read a temporary shortcut with spaces in the path so the PowerShell/COM invocation remains covered.
2026-06-16 21:40:48 +03:00
mixeme 0bc9e91d1e Stabilize Jobs details panel width
Wrap dynamic job detail labels so long names, schedules, commands, and status values no longer change the right panel minimum width when the selected job changes.

Point embedded and Windows resource icons at the current asset filenames so tests and Windows builds continue to work after the asset cleanup.
2026-06-16 21:24:03 +03:00
12 changed files with 282 additions and 58 deletions
+8 -7
View File
@@ -274,6 +274,7 @@ Standard 5-field cron schedules:
Changing `jobs_dir` saves the current job list to the new directory. Changing `jobs_dir` saves the current job list to the new directory.
The `Start on login` setting shows an `OK` or `Problem` status next to the checkbox. Saving settings with the checkbox enabled rewrites the autostart entry using the current executable path. The `Start on login` setting shows an `OK` or `Problem` status next to the checkbox. Saving settings with the checkbox enabled rewrites the autostart entry using the current executable path.
Autostart entries add `--start-in-tray`, so scheduled jobs begin running after sign-in without opening the main window.
## Autostart ## Autostart
@@ -292,19 +293,19 @@ Linux:
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=PySentry Name=PySentry
Exec=/opt/pysentry/pysentry-0.2.4-linux-amd64 Exec=/opt/pysentry/pysentry-0.2.4-linux-amd64 --start-in-tray
Terminal=false Terminal=false
``` ```
Windows: Windows:
```text ```text
# PySentry writes an HKCU Run entry when Start on login is enabled. It needs no # PySentry writes a shortcut to the current user's Startup folder when Start on
# administrator rights and starts PySentry when the current user signs in. Task # login is enabled. A .lnk stores the executable path as a structured TargetPath,
# Scheduler remains a later option if delayed start or elevated tasks become # and stores --start-in-tray as Arguments, so paths with spaces do not need
# necessary. Saving settings with the checkbox enabled rewrites this entry, so it # fragile command-line quoting. Saving settings rewrites the shortcut and removes
# repairs an old path after the executable was moved or renamed. # old HKCU Run entries from earlier builds.
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\PySentry %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\GoSentry.lnk
``` ```
## Project Layout ## Project Layout
+2 -2
View File
@@ -14,7 +14,7 @@ import (
// The blank import enables the compiler directive below; no runtime package // The blank import enables the compiler directive below; no runtime package
// initialization from embed is required. // initialization from embed is required.
// //
//go:embed pysentry-icon.png //go:embed pysentry-icon-big.png
var iconBytes []byte var iconBytes []byte
func Icon() fyne.Resource { func Icon() fyne.Resource {
@@ -22,7 +22,7 @@ func Icon() fyne.Resource {
// for the window icon and tray icon. The Windows Explorer icon is still added // for the window icon and tray icon. The Windows Explorer icon is still added
// by the build script through the .ico resource, because Explorer reads PE // by the build script through the .ico resource, because Explorer reads PE
// resources rather than Fyne runtime state. // resources rather than Fyne runtime state.
return fyne.NewStaticResource("pysentry-icon.png", iconBytes) return fyne.NewStaticResource("pysentry-icon-big.png", iconBytes)
} }
func IconBytes() []byte { func IconBytes() []byte {
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+16 -2
View File
@@ -1,10 +1,24 @@
package main package main
import "github.com/pysentry/pysentry/src/gui" import (
"os"
"github.com/pysentry/pysentry/src/core"
"github.com/pysentry/pysentry/src/gui"
)
func main() { func main() {
// The executable entry point intentionally delegates all startup work to the // The executable entry point intentionally delegates all startup work to the
// GUI package. Keeping main small makes it easier to add platform-specific // GUI package. Keeping main small makes it easier to add platform-specific
// packaging later without mixing window setup, storage, and scheduler logic. // packaging later without mixing window setup, storage, and scheduler logic.
gui.Run() gui.Run(hasArgument(core.StartInTrayArgument))
}
func hasArgument(argument string) bool {
for _, current := range os.Args[1:] {
if current == argument {
return true
}
}
return false
} }
+5 -3
View File
@@ -14,7 +14,7 @@ flowchart LR
store["src/core Store - YAML config and jobs"] store["src/core Store - YAML config and jobs"]
scheduler["src/core Scheduler - @every and cron timing"] scheduler["src/core Scheduler - @every and cron timing"]
runner["src/core Runner - shell command execution"] runner["src/core Runner - shell command execution"]
autostart["src/core Autostart - Windows Run / Linux desktop startup"] autostart["src/core Autostart - Windows Startup shortcut / Linux desktop startup"]
config["pysentry.yaml - application settings"] config["pysentry.yaml - application settings"]
jobs["jobs.yaml - job definitions"] jobs["jobs.yaml - job definitions"]
logs["logs_dir - per-run command output logs"] logs["logs_dir - per-run command output logs"]
@@ -67,5 +67,7 @@ flowchart LR
runs log cleanup, and calls the GUI callback so the `History` tab refreshes. runs log cleanup, and calls the GUI callback so the `History` tab refreshes.
7. Autostart: 7. Autostart:
The Settings tab calls the platform autostart implementation. Windows uses the The Settings tab calls the platform autostart implementation. Windows uses a
current user's Run registry key. Linux uses a desktop-session startup entry. shortcut in the current user's Startup folder. Linux uses a desktop-session
startup entry. Both autostart mechanisms pass `--start-in-tray`, so the
scheduler starts without opening the main window after sign-in.
+1 -1
View File
@@ -1 +1 @@
IDI_ICON1 ICON "assets/pysentry.ico" IDI_ICON1 ICON "assets/gosentry.ico"
+4 -3
View File
@@ -30,11 +30,11 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
Type=Application Type=Application
Name=PySentry Name=PySentry
Comment=PySentry desktop scheduler Comment=PySentry desktop scheduler
Exec=%s Exec=%s %s
%s %s
Terminal=false Terminal=false
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), desktopIconLine(iconPath)) `, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644) return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
} }
@@ -63,7 +63,8 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if readErr != nil { if readErr != nil {
return false, "Autostart desktop entry is missing" return false, "Autostart desktop entry is missing"
} }
if !strings.Contains(string(data), "Exec="+quoteDesktopExec(executablePath)) { expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable" return false, "Autostart desktop entry points to another executable"
} }
return true, "Autostart is configured" return true, "Autostart is configured"
+32
View File
@@ -0,0 +1,32 @@
//go:build linux
package core
import (
"os"
"strings"
"testing"
)
func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := SetAutostart(true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
t.Fatalf("enable autostart: %v", err)
}
desktopPath, err := autostartDesktopPath()
if err != nil {
t.Fatalf("resolve desktop path: %v", err)
}
data, err := os.ReadFile(desktopPath)
if err != nil {
t.Fatalf("read desktop entry: %v", err)
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry does not start in tray: %s", data)
}
}
+130 -28
View File
@@ -1,57 +1,159 @@
package core package core
import ( import (
"fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
) )
const autostartName = "PySentry" const autostartName = "GoSentry"
const legacyAutostartName = "PySentry"
const startupShortcutFile = autostartName + ".lnk"
func SetAutostart(enabled bool, executablePath string, iconPath string) error { func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if enabled { if err := cleanupLegacyRegistryAutostart(); err != nil {
// Remove any stale entry first. This makes "uncheck, save, check, save" return err
// 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", strconv.Quote(executablePath), "/f")
configureHiddenWindow(command)
return command.Run()
} }
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f")
configureHiddenWindow(command) shortcutPath, err := startupShortcutPath()
_ = command.Run() if err != nil {
return nil return err
}
if enabled {
return createStartupShortcut(shortcutPath, executablePath, iconPath)
}
return removeIfExists(shortcutPath)
} }
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) { func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName) shortcutPath, err := startupShortcutPath()
configureHiddenWindow(command)
output, err := command.Output()
if !expectedEnabled {
if err != nil { if err != nil {
return false, "Startup folder cannot be resolved"
}
_, statErr := os.Stat(shortcutPath)
if !expectedEnabled {
if os.IsNotExist(statErr) {
if legacyRegistryAutostartExists() {
return false, "Legacy registry autostart exists; save settings to repair"
}
return true, "Autostart is off" return true, "Autostart is off"
} }
return false, "Autostart entry exists while setting is off" if statErr != nil {
return false, "Autostart shortcut cannot be checked"
} }
if err != nil { return false, "Autostart shortcut exists while setting is off"
return false, "Autostart entry is missing"
} }
actual, ok := parseRegistryRunValue(string(output)) if os.IsNotExist(statErr) {
if !ok { if legacyRegistryAutostartExists() {
return false, "Autostart entry cannot be read" return false, "Legacy registry autostart exists; save settings to repair"
}
return false, "Autostart shortcut is missing"
}
if statErr != nil {
return false, "Autostart shortcut cannot be checked"
}
actual, arguments, err := readShortcut(shortcutPath)
if err != nil {
return false, "Autostart shortcut cannot be read"
} }
if !sameWindowsPath(actual, executablePath) { if !sameWindowsPath(actual, executablePath) {
return false, "Autostart points to another executable" return false, "Autostart shortcut points to another executable"
}
if strings.TrimSpace(arguments) != StartInTrayArgument {
return false, "Autostart shortcut does not start in tray"
} }
return true, "Autostart is configured" return true, "Autostart is configured"
} }
func startupShortcutPath() (string, error) {
appData := os.Getenv("APPDATA")
if appData == "" {
return "", fmt.Errorf("APPDATA is not set")
}
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
}
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string) error {
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
return err
}
workingDirectory := filepath.Dir(executablePath)
if iconPath == "" {
iconPath = executablePath
}
script := `$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); $shortcut.TargetPath = $env:GOSENTRY_TARGET_PATH; $shortcut.Arguments = $env:GOSENTRY_ARGUMENTS; $shortcut.WorkingDirectory = $env:GOSENTRY_WORKING_DIRECTORY; $shortcut.IconLocation = $env:GOSENTRY_ICON_PATH; $shortcut.Save()`
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+StartInTrayArgument,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath,
)
configureHiddenWindow(command)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
}
return nil
}
func readShortcut(shortcutPath string) (string, string, error) {
script := `$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
configureHiddenWindow(command)
output, err := command.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
}
lines := strings.SplitN(string(output), "\n", 2)
target := strings.TrimSpace(lines[0])
arguments := ""
if len(lines) > 1 {
arguments = strings.TrimSpace(lines[1])
}
return target, arguments, nil
}
func readShortcutTarget(shortcutPath string) (string, error) {
target, _, err := readShortcut(shortcutPath)
return target, err
}
func removeIfExists(path string) error {
err := os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
return err
}
func cleanupLegacyRegistryAutostart() error {
for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
configureHiddenWindow(command)
_ = command.Run()
}
return nil
}
func legacyRegistryAutostartExists() bool {
for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
configureHiddenWindow(command)
if command.Run() == nil {
return true
}
}
return false
}
func parseRegistryRunValue(output string) (string, bool) { func parseRegistryRunValue(output string) (string, bool) {
for _, line := range strings.Split(output, "\n") { for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(strings.TrimSpace(line)) fields := strings.Fields(strings.TrimSpace(line))
+52 -1
View File
@@ -2,7 +2,11 @@
package core package core
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestParseRegistryRunValue(t *testing.T) { func TestParseRegistryRunValue(t *testing.T) {
output := ` output := `
@@ -23,3 +27,50 @@ func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
t.Fatal("expected paths to match") t.Fatal("expected paths to match")
} }
} }
func TestSameWindowsPathHandlesSpaces(t *testing.T) {
if !sameWindowsPath(`"D:\Local Git\GoSentry\gosentry.exe"`, `d:\local git\gosentry\gosentry.exe`) {
t.Fatal("expected paths with spaces to match")
}
}
func TestStartupShortcutPathUsesUserStartupFolder(t *testing.T) {
t.Setenv("APPDATA", `C:\Users\mixem\AppData\Roaming`)
path, err := startupShortcutPath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := `C:\Users\mixem\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\GoSentry.lnk`
if path != expected {
t.Fatalf("unexpected shortcut path: %q", path)
}
}
func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")
targetPath := filepath.Join(tempDir, "Program Files", "GoSentry", "gosentry.exe")
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
t.Fatalf("create target directory: %v", err)
}
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
t.Fatalf("create shortcut: %v", err)
}
actual, arguments, err := readShortcut(shortcutPath)
if err != nil {
t.Fatalf("read shortcut: %v", err)
}
if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
}
if arguments != StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
}
}
+5
View File
@@ -2,6 +2,11 @@ package core
import "time" import "time"
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
// can keep the scheduler running without flashing the main window. Manual
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// Config is stored in pysentry.yaml next to the program. It contains only // Config is stored in pysentry.yaml next to the program. It contains only
// application-level choices: where to read jobs from, where to write logs, and // application-level choices: where to read jobs from, where to write logs, and
// how the desktop shell should behave. // how the desktop shell should behave.
+25 -9
View File
@@ -42,9 +42,9 @@ const singleInstanceShowCommand = "show"
type job = core.Job type job = core.Job
type event = core.RunRecord type event = core.RunRecord
func Run() { func Run(startInTray bool) {
started := time.Now() started := time.Now()
instanceListener, primary := acquireSingleInstance() instanceListener, primary := acquireSingleInstance(!startInTray)
if !primary { if !primary {
return return
} }
@@ -62,6 +62,10 @@ func Run() {
w.Resize(fyne.NewSize(1120, 720)) w.Resize(fyne.NewSize(1120, 720))
w.SetContent(newMainView(w, started)) w.SetContent(newMainView(w, started))
serveSingleInstance(instanceListener, w) serveSingleInstance(instanceListener, w)
if startInTray {
a.Run()
return
}
w.ShowAndRun() w.ShowAndRun()
} }
@@ -96,7 +100,7 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
}) })
} }
func acquireSingleInstance() (net.Listener, bool) { func acquireSingleInstance(showExisting bool) (net.Listener, bool) {
listener, err := net.Listen("tcp", singleInstanceAddress) listener, err := net.Listen("tcp", singleInstanceAddress)
if err == nil { if err == nil {
return listener, true return listener, true
@@ -104,7 +108,9 @@ func acquireSingleInstance() (net.Listener, bool) {
connection, dialErr := net.DialTimeout("tcp", singleInstanceAddress, time.Second) connection, dialErr := net.DialTimeout("tcp", singleInstanceAddress, time.Second)
if dialErr == nil { if dialErr == nil {
if showExisting {
_, _ = io.WriteString(connection, singleInstanceShowCommand) _, _ = io.WriteString(connection, singleInstanceShowCommand)
}
_ = connection.Close() _ = connection.Close()
return nil, false return nil, false
} }
@@ -157,12 +163,13 @@ func newMainView(w fyne.Window, started time.Time) fyne.CanvasObject {
schedulerPaused := false schedulerPaused := false
filteredJobs := filteredJobIndexes(jobs, selectedFolder) filteredJobs := filteredJobIndexes(jobs, selectedFolder)
title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
folder := widget.NewLabel(jobs[selected].Folder) title.Wrapping = fyne.TextWrapBreak
schedule := widget.NewLabel(jobs[selected].Schedule) folder := newJobDetailLabel(jobs[selected].Folder)
command := widget.NewLabel(jobs[selected].Command) schedule := newJobDetailLabel(jobs[selected].Schedule)
lastRun := widget.NewLabel(jobs[selected].LastRun) command := newJobDetailLabel(jobs[selected].Command)
nextRun := widget.NewLabel(jobs[selected].NextRun) lastRun := newJobDetailLabel(jobs[selected].LastRun)
state := widget.NewLabel(jobs[selected].LastState) nextRun := newJobDetailLabel(jobs[selected].NextRun)
state := newJobDetailLabel(jobs[selected].LastState)
schedulerState := widget.NewLabel("Scheduler running") schedulerState := widget.NewLabel("Scheduler running")
commandOutput := widget.NewTextGrid() commandOutput := widget.NewTextGrid()
commandOutput.SetText(jobs[selected].Output) commandOutput.SetText(jobs[selected].Output)
@@ -569,6 +576,15 @@ func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
return container.NewGridWithColumns(2, caption, value) return container.NewGridWithColumns(2, caption, value)
} }
func newJobDetailLabel(text string) *widget.Label {
label := widget.NewLabel(text)
// Job names, commands, and paths can be much wider than the details panel.
// Breaking long runs of text keeps Label.MinSize stable when the selection
// changes, so the right panel does not force the whole window to resize.
label.Wrapping = fyne.TextWrapBreak
return label
}
func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject { func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
caption.Wrapping = fyne.TextTruncate caption.Wrapping = fyne.TextTruncate