From b1fe8bd67594e4f52ab0dbbd49eac83457822963 Mon Sep 17 00:00:00 2001 From: mixeme Date: Tue, 16 Jun 2026 21:52:00 +0300 Subject: [PATCH] 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. --- README.md | 8 +++++--- cmd/pysentry/main.go | 18 +++++++++++++++-- docs/ARCHITECTURE.md | 3 ++- src/core/autostart_linux.go | 7 ++++--- src/core/autostart_linux_test.go | 32 ++++++++++++++++++++++++++++++ src/core/autostart_windows.go | 27 +++++++++++++++++++------ src/core/autostart_windows_test.go | 5 ++++- src/core/model.go | 5 +++++ src/gui/app.go | 14 +++++++++---- 9 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 src/core/autostart_linux_test.go diff --git a/README.md b/README.md index bd53a9f..589b862 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,7 @@ Standard 5-field cron schedules: 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. +Autostart entries add `--start-in-tray`, so scheduled jobs begin running after sign-in without opening the main window. ## Autostart @@ -292,7 +293,7 @@ Linux: [Desktop Entry] Type=Application 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 ``` @@ -301,8 +302,9 @@ Windows: ```text # PySentry writes a shortcut to the current user's Startup folder when Start on # login is enabled. A .lnk stores the executable path as a structured TargetPath, -# so paths with spaces do not need fragile command-line quoting. Saving settings -# rewrites the shortcut and removes old HKCU Run entries from earlier builds. +# and stores --start-in-tray as Arguments, so paths with spaces do not need +# fragile command-line quoting. Saving settings rewrites the shortcut and removes +# old HKCU Run entries from earlier builds. %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\GoSentry.lnk ``` diff --git a/cmd/pysentry/main.go b/cmd/pysentry/main.go index 7fc550c..c318839 100644 --- a/cmd/pysentry/main.go +++ b/cmd/pysentry/main.go @@ -1,10 +1,24 @@ 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() { // The executable entry point intentionally delegates all startup work to the // GUI package. Keeping main small makes it easier to add platform-specific // 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 } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 188c609..6426c4a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -69,4 +69,5 @@ flowchart LR 7. Autostart: The Settings tab calls the platform autostart implementation. Windows uses a shortcut in the current user's Startup folder. Linux uses a desktop-session - startup entry. + startup entry. Both autostart mechanisms pass `--start-in-tray`, so the + scheduler starts without opening the main window after sign-in. diff --git a/src/core/autostart_linux.go b/src/core/autostart_linux.go index 36a312c..f7e18cc 100644 --- a/src/core/autostart_linux.go +++ b/src/core/autostart_linux.go @@ -30,11 +30,11 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error { Type=Application Name=PySentry Comment=PySentry desktop scheduler -Exec=%s +Exec=%s %s %s Terminal=false X-GNOME-Autostart-enabled=true -`, quoteDesktopExec(executablePath), desktopIconLine(iconPath)) +`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath)) return os.WriteFile(desktopPath, []byte(desktopFile), 0o644) } @@ -63,7 +63,8 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) if readErr != nil { 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 true, "Autostart is configured" diff --git a/src/core/autostart_linux_test.go b/src/core/autostart_linux_test.go new file mode 100644 index 0000000..facba5d --- /dev/null +++ b/src/core/autostart_linux_test.go @@ -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) + } +} diff --git a/src/core/autostart_windows.go b/src/core/autostart_windows.go index f8be653..7e0b25e 100644 --- a/src/core/autostart_windows.go +++ b/src/core/autostart_windows.go @@ -57,13 +57,16 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) return false, "Autostart shortcut cannot be checked" } - actual, err := readShortcutTarget(shortcutPath) + actual, arguments, err := readShortcut(shortcutPath) if err != nil { return false, "Autostart shortcut cannot be read" } if !sameWindowsPath(actual, executablePath) { 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" } @@ -84,11 +87,12 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath 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.WorkingDirectory = $env:GOSENTRY_WORKING_DIRECTORY; $shortcut.IconLocation = $env:GOSENTRY_ICON_PATH; $shortcut.Save()` + 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, ) @@ -99,16 +103,27 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath return nil } -func readShortcutTarget(shortcutPath string) (string, error) { - script := `$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath)` +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))) + return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output))) } - return strings.TrimSpace(string(output)), nil + 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 { diff --git a/src/core/autostart_windows_test.go b/src/core/autostart_windows_test.go index a6355d2..f085eb0 100644 --- a/src/core/autostart_windows_test.go +++ b/src/core/autostart_windows_test.go @@ -63,11 +63,14 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) { t.Fatalf("create shortcut: %v", err) } - actual, err := readShortcutTarget(shortcutPath) + 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) + } } diff --git a/src/core/model.go b/src/core/model.go index c92ec34..dd55e96 100644 --- a/src/core/model.go +++ b/src/core/model.go @@ -2,6 +2,11 @@ package core 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 // application-level choices: where to read jobs from, where to write logs, and // how the desktop shell should behave. diff --git a/src/gui/app.go b/src/gui/app.go index 288632e..2e74cfd 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -42,9 +42,9 @@ const singleInstanceShowCommand = "show" type job = core.Job type event = core.RunRecord -func Run() { +func Run(startInTray bool) { started := time.Now() - instanceListener, primary := acquireSingleInstance() + instanceListener, primary := acquireSingleInstance(!startInTray) if !primary { return } @@ -62,6 +62,10 @@ func Run() { w.Resize(fyne.NewSize(1120, 720)) w.SetContent(newMainView(w, started)) serveSingleInstance(instanceListener, w) + if startInTray { + a.Run() + return + } 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) if err == nil { return listener, true @@ -104,7 +108,9 @@ func acquireSingleInstance() (net.Listener, bool) { connection, dialErr := net.DialTimeout("tcp", singleInstanceAddress, time.Second) if dialErr == nil { - _, _ = io.WriteString(connection, singleInstanceShowCommand) + if showExisting { + _, _ = io.WriteString(connection, singleInstanceShowCommand) + } _ = connection.Close() return nil, false }