T1.6: Create src/platform/autostart; move autostart logic

Move autostart_*.go and tests from src/core to src/platform/autostart.
Update src/gui/app.go to call autostart.SetAutostart / autostart.AutostartStatus.
Add quoteDesktopExec to desktop_linux.go (was co-located in autostart_linux.go).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-18 22:35:51 +03:00
parent 06edbfff77
commit 16d818d03d
8 changed files with 14 additions and 8 deletions
+185
View File
@@ -0,0 +1,185 @@
//go:build linux
package autostart
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
const autostartDesktopFileName = "gosentry.desktop"
const legacyAutostartDesktopFileName = "pysentry.desktop"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
desktopPath, err := autostartDesktopPath()
if err != nil {
return err
}
// A desktop scheduler with a tray icon belongs to the graphical session, so
// Linux autostart is implemented through XDG Autostart instead of a systemd
// user service. systemd is tempting because it is explicit and scriptable,
// but it is the wrong owner for a windowed app that should inherit the
// desktop session environment and appear in the tray predictably.
if err := cleanupLegacySystemdAutostart(); err != nil {
return err
}
if err := cleanupLegacyDesktopAutostart(); err != nil {
return err
}
if enabled {
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
return err
}
desktopFile := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=GoSentry
Comment=GoSentry desktop scheduler
Exec=%s %s
%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
}
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
desktopPath, err := autostartDesktopPath()
if err != nil {
return false, "Cannot resolve XDG autostart directory"
}
if legacySystemdAutostartExists() {
return false, "Legacy systemd autostart entry still exists"
}
if legacyDesktopAutostartExists() {
return false, "Legacy desktop autostart entry still exists"
}
data, readErr := os.ReadFile(desktopPath)
if !expectedEnabled {
if os.IsNotExist(readErr) {
return true, "Autostart is off"
}
return false, "Autostart desktop entry exists while setting is off"
}
if readErr != nil {
return false, "Autostart desktop entry is missing"
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable"
}
return true, "Autostart is configured"
}
func autostartDesktopPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configHome = filepath.Join(home, ".config")
}
return filepath.Join(configHome, "autostart", autostartDesktopFileName), nil
}
func legacyAutostartDesktopPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configHome = filepath.Join(home, ".config")
}
return filepath.Join(configHome, "autostart", legacyAutostartDesktopFileName), nil
}
func quoteDesktopExec(path string) string {
return strconv.Quote(path)
}
func desktopIconLine(iconPath string) string {
if strings.TrimSpace(iconPath) == "" {
return ""
}
return "Icon=" + iconPath
}
func cleanupLegacySystemdAutostart() error {
unitPath, err := legacySystemdUnitPath()
if err != nil {
return err
}
if _, err := os.Stat(unitPath); os.IsNotExist(err) {
return nil
}
// Older PySentry builds used a systemd user unit for autostart. The current
// GoSentry implementation uses XDG Autostart because it is a GUI/tray
// application and should be launched by the desktop session. Disable and
// remove the old unit so the two mechanisms do not fight or start duplicates.
_ = exec.Command("systemctl", "--user", "disable", "pysentry.service").Run()
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return err
}
_ = exec.Command("systemctl", "--user", "daemon-reload").Run()
return nil
}
func cleanupLegacyDesktopAutostart() error {
desktopPath, err := legacyAutostartDesktopPath()
if err != nil {
return err
}
// The old PySentry desktop file is removed proactively instead of tolerated
// alongside the new one. Leaving both files in place would risk duplicate
// launches or confusing status diagnostics after the rename.
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func legacyDesktopAutostartExists() bool {
desktopPath, err := legacyAutostartDesktopPath()
if err != nil {
return false
}
_, err = os.Stat(desktopPath)
return err == nil
}
func legacySystemdAutostartExists() bool {
unitPath, err := legacySystemdUnitPath()
if err != nil {
return false
}
_, err = os.Stat(unitPath)
return err == nil
}
func legacySystemdUnitPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configHome = filepath.Join(home, ".config")
}
return filepath.Join(configHome, "systemd", "user", "pysentry.service"), nil
}
@@ -0,0 +1,57 @@
//go:build linux
package autostart
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
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) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry does not start in tray: %s", data)
}
}
func TestLinuxAutostartRemovesLegacyDesktopEntry(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
legacyPath, err := legacyAutostartDesktopPath()
if err != nil {
t.Fatalf("resolve legacy desktop path: %v", err)
}
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
t.Fatalf("create legacy desktop directory: %v", err)
}
if err := os.WriteFile(legacyPath, []byte("[Desktop Entry]\nName=PySentry\n"), 0o644); err != nil {
t.Fatalf("write legacy desktop entry: %v", err)
}
if err := SetAutostart(true, "/opt/gosentry/gosentry", ""); err != nil {
t.Fatalf("enable autostart: %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("legacy desktop entry still exists or cannot be checked: %v", err)
}
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows && !linux
package autostart
import "fmt"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if !enabled {
return nil
}
return fmt.Errorf("autostart is not implemented for this platform")
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
if !expectedEnabled {
return true, "Autostart is off"
}
return false, "Autostart is not implemented for this platform"
}
+220
View File
@@ -0,0 +1,220 @@
package autostart
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
)
const autostartName = "GoSentry"
const legacyAutostartName = "PySentry"
const startupShortcutFile = autostartName + ".lnk"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
// Windows autostart used to write HKCU\Run values, but that approach became
// brittle once paths with spaces and the "--start-in-tray" argument entered
// the picture. A Startup-folder shortcut stores target path and arguments as
// separate structured fields, so it avoids quoting bugs and more closely
// matches how a user would configure a GUI app by hand.
if err := cleanupLegacyRegistryAutostart(); err != nil {
return err
}
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) {
shortcutPath, err := startupShortcutPath()
if err != nil {
return false, "Startup folder cannot be resolved"
}
_, statErr := os.Stat(shortcutPath)
if !expectedEnabled {
if os.IsNotExist(statErr) {
if legacyRegistryAutostartExists() {
return false, "Legacy registry autostart exists; save settings to repair"
}
return true, "Autostart is off"
}
if statErr != nil {
return false, "Autostart shortcut cannot be checked"
}
return false, "Autostart shortcut exists while setting is off"
}
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, 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) != domain.StartInTrayArgument {
return false, "Autostart shortcut does not start in tray"
}
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
}
// WScript.Shell is used here deliberately instead of a third-party Go COM
// wrapper. The PowerShell bridge is not glamorous, but it is already present
// on supported Windows systems and keeps the dependency surface much smaller
// for a project that otherwise aims to stay light.
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="+domain.StartInTrayArgument,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath,
)
winproc.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 readShortcut(shortcutPath string) (string, string, error) {
// Force UTF-8 before writing the path. PowerShell defaults to the system
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
// os.Executable, causing AutostartStatus to report "shortcut points to
// another executable" for any install path that contains non-ASCII chars.
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $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)
winproc.ConfigureHiddenWindow(command)
output, err := command.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
}
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 {
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")
winproc.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)
winproc.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))
for index, field := range fields {
if field == "REG_SZ" && index+1 < len(fields) {
value := strings.Join(fields[index+1:], " ")
value = strings.Trim(value, `"`)
return value, value != ""
}
}
}
return "", false
}
func sameWindowsPath(left string, right string) bool {
left = normalizeWindowsPath(left)
right = normalizeWindowsPath(right)
if strings.EqualFold(left, right) {
return true
}
// If the string comparison fails, compare by filesystem object identity.
// os.SameFile uses the volume serial number and file index on Windows, so
// it correctly handles cases where one path uses an NTFS 8.3 short name
// while the other uses the long name. Windows generates 8.3 names for
// directory entries that contain spaces; when the process is launched via
// a Startup-folder shortcut the OS may resolve the PIDL to the short-name
// form, so os.Executable can return a different string than WScript reads
// back from TargetPath even though both point to the same file. The same
// fallback also covers directory junction points.
leftInfo, leftErr := os.Lstat(left)
rightInfo, rightErr := os.Lstat(right)
if leftErr == nil && rightErr == nil {
return os.SameFile(leftInfo, rightInfo)
}
return false
}
func normalizeWindowsPath(p string) string {
p = strings.Trim(p, `"`)
// filepath.Clean preserves the \\?\ extended-length device path prefix that
// Windows adds for paths exceeding MAX_PATH. Strip it so the cleaned result
// compares equal to the same path without the prefix.
p = strings.TrimPrefix(p, `\\?\`)
return filepath.Clean(p)
}
@@ -0,0 +1,146 @@
//go:build windows
package autostart
import (
"os"
"path/filepath"
"syscall"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestParseRegistryRunValue(t *testing.T) {
output := `
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run
GoSentry REG_SZ "D:\Apps\GoSentry\gosentry.exe"
`
value, ok := parseRegistryRunValue(output)
if !ok {
t.Fatal("expected registry value to parse")
}
if value != `D:\Apps\GoSentry\gosentry.exe` {
t.Fatalf("unexpected value: %q", value)
}
}
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
if !sameWindowsPath(`"D:\Apps\GoSentry\gosentry.exe"`, `d:\apps\gosentry\gosentry.exe`) {
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 TestSameWindowsPathStripsExtendedLengthPrefix(t *testing.T) {
if !sameWindowsPath(`\\?\D:\Apps\GoSentry\gosentry.exe`, `D:\Apps\GoSentry\gosentry.exe`) {
t.Fatal("expected \\\\?\\-prefixed path to match plain path")
}
}
func TestSameWindowsPathMatchesShortNameViaFilesystem(t *testing.T) {
// Create a file inside a directory whose name contains a space. On NTFS
// systems that have 8.3 name generation enabled, Windows also assigns a
// short name to the directory (e.g. "Local~1"). WScript.Shell may return
// the long form while os.Executable returns the short form (or vice versa).
// Verify that sameWindowsPath treats both representations as equal.
tempDir := t.TempDir()
dirWithSpace := filepath.Join(tempDir, "Local Git")
if err := os.MkdirAll(dirWithSpace, 0755); err != nil {
t.Fatalf("create dir: %v", err)
}
longPath := filepath.Join(dirWithSpace, "gosentry.exe")
if err := os.WriteFile(longPath, []byte("test"), 0644); err != nil {
t.Fatalf("create file: %v", err)
}
// GetShortPathName converts the long path to its 8.3 equivalent when 8.3
// names are available; it returns the unchanged path otherwise.
p16, err := syscall.UTF16PtrFromString(longPath)
if err != nil {
t.Fatalf("UTF16PtrFromString: %v", err)
}
buf := make([]uint16, syscall.MAX_PATH)
n, err := syscall.GetShortPathName(p16, &buf[0], uint32(len(buf)))
if err != nil {
t.Skipf("GetShortPathName: %v", err)
}
shortPath := syscall.UTF16ToString(buf[:n])
if !sameWindowsPath(longPath, shortPath) {
t.Fatalf("sameWindowsPath(%q, %q) = false; want true", longPath, shortPath)
}
}
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 TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry.lnk")
targetPath := filepath.Join(tempDir, "Программы и драйвера", "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, 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 != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
}
}
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, 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 != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
}
}