From 44f24ab3d88ebb2aaa85564c2eefb0c9a4c15c6c Mon Sep 17 00:00:00 2001 From: mixeme Date: Tue, 16 Jun 2026 21:40:48 +0300 Subject: [PATCH] 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. --- README.md | 11 +-- docs/ARCHITECTURE.md | 7 +- src/core/autostart_windows.go | 145 +++++++++++++++++++++++------ src/core/autostart_windows_test.go | 50 +++++++++- 4 files changed, 174 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index e936ab1..bd53a9f 100644 --- a/README.md +++ b/README.md @@ -299,12 +299,11 @@ Terminal=false Windows: ```text -# PySentry writes an HKCU Run entry when Start on login is enabled. It needs no -# administrator rights and starts PySentry when the current user signs in. Task -# Scheduler remains a later option if delayed start or elevated tasks become -# necessary. Saving settings with the checkbox enabled rewrites this entry, so it -# repairs an old path after the executable was moved or renamed. -HKCU\Software\Microsoft\Windows\CurrentVersion\Run\PySentry +# 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. +%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\GoSentry.lnk ``` ## Project Layout diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f1538f..188c609 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,7 +14,7 @@ flowchart LR store["src/core Store - YAML config and jobs"] scheduler["src/core Scheduler - @every and cron timing"] 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"] jobs["jobs.yaml - job definitions"] logs["logs_dir - per-run command output logs"] @@ -67,5 +67,6 @@ flowchart LR runs log cleanup, and calls the GUI callback so the `History` tab refreshes. 7. Autostart: - The Settings tab calls the platform autostart implementation. Windows uses the - current user's Run registry key. Linux uses a desktop-session startup entry. + 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. diff --git a/src/core/autostart_windows.go b/src/core/autostart_windows.go index cd2d16f..f8be653 100644 --- a/src/core/autostart_windows.go +++ b/src/core/autostart_windows.go @@ -1,57 +1,144 @@ package core import ( + "fmt" + "os" "os/exec" "path/filepath" - "strconv" "strings" ) -const autostartName = "PySentry" +const autostartName = "GoSentry" +const legacyAutostartName = "PySentry" +const startupShortcutFile = autostartName + ".lnk" func SetAutostart(enabled bool, executablePath string, iconPath string) error { - if enabled { - // Remove any stale entry first. This makes "uncheck, save, check, save" - // 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() + if err := cleanupLegacyRegistryAutostart(); err != nil { + return err } - command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f") - configureHiddenWindow(command) - _ = command.Run() - return nil + + shortcutPath, err := startupShortcutPath() + if err != nil { + return err + } + + if enabled { + return createStartupShortcut(shortcutPath, executablePath, iconPath) + } + return removeIfExists(shortcutPath) } func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) { - command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName) - configureHiddenWindow(command) - output, err := command.Output() + shortcutPath, err := startupShortcutPath() + if err != nil { + return false, "Startup folder cannot be resolved" + } + _, statErr := os.Stat(shortcutPath) if !expectedEnabled { - if err != nil { + if os.IsNotExist(statErr) { + if legacyRegistryAutostartExists() { + return false, "Legacy registry autostart exists; save settings to repair" + } return true, "Autostart is off" } - return false, "Autostart entry exists while setting is off" - } - if err != nil { - return false, "Autostart entry is missing" + if statErr != nil { + return false, "Autostart shortcut cannot be checked" + } + return false, "Autostart shortcut exists while setting is off" } - actual, ok := parseRegistryRunValue(string(output)) - if !ok { - return false, "Autostart entry cannot be read" + if os.IsNotExist(statErr) { + if legacyRegistryAutostartExists() { + 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, err := readShortcutTarget(shortcutPath) + if err != nil { + return false, "Autostart shortcut cannot be read" } if !sameWindowsPath(actual, executablePath) { - return false, "Autostart points to another executable" + return false, "Autostart shortcut points to another executable" } 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.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_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 readShortcutTarget(shortcutPath string) (string, error) { + script := `$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath)` + 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 strings.TrimSpace(string(output)), nil +} + +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) { for _, line := range strings.Split(output, "\n") { fields := strings.Fields(strings.TrimSpace(line)) diff --git a/src/core/autostart_windows_test.go b/src/core/autostart_windows_test.go index 7276446..a6355d2 100644 --- a/src/core/autostart_windows_test.go +++ b/src/core/autostart_windows_test.go @@ -2,7 +2,11 @@ package core -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestParseRegistryRunValue(t *testing.T) { output := ` @@ -23,3 +27,47 @@ func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) { 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, err := readShortcutTarget(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) + } +}