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) + } +}