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.
This commit is contained in:
@@ -299,12 +299,11 @@ 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
|
# so paths with spaces do not need fragile command-line quoting. Saving settings
|
||||||
# necessary. Saving settings with the checkbox enabled rewrites this entry, so it
|
# rewrites the shortcut and removes old HKCU Run entries from earlier builds.
|
||||||
# repairs an old path after the executable was moved or renamed.
|
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\GoSentry.lnk
|
||||||
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\PySentry
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|||||||
@@ -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,6 @@ 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.
|
||||||
|
|||||||
+116
-29
@@ -1,57 +1,144 @@
|
|||||||
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)
|
if err != nil {
|
||||||
output, err := command.Output()
|
return false, "Startup folder cannot be resolved"
|
||||||
|
}
|
||||||
|
_, statErr := os.Stat(shortcutPath)
|
||||||
if !expectedEnabled {
|
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 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 entry is missing"
|
return false, "Autostart shortcut exists while setting is off"
|
||||||
}
|
}
|
||||||
|
|
||||||
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, err := readShortcutTarget(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"
|
||||||
}
|
}
|
||||||
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.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) {
|
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))
|
||||||
|
|||||||
@@ -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,47 @@ 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, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user