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 }