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" ) type windowsManager struct{} // New returns the Windows autostart Manager. func New() Manager { return windowsManager{} } func (windowsManager) Set(enabled, startInTray bool, executablePath, iconPath string) error { return setAutostart(enabled, startInTray, executablePath, iconPath) } func (windowsManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) { return autostartStatus(expectedEnabled, startInTray, executablePath) } const autostartName = "GoSentry" const startupShortcutFile = autostartName + ".lnk" func setAutostart(enabled bool, startInTray 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. shortcutPath, err := startupShortcutPath() if err != nil { return err } if enabled { return createStartupShortcut(shortcutPath, executablePath, iconPath, domain.AutostartArguments(startInTray)) } return removeIfExists(shortcutPath) } func autostartStatus(expectedEnabled bool, startInTray 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) { 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) { 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" } expectedArgs := domain.AutostartArguments(startInTray) if strings.TrimSpace(arguments) != expectedArgs { if startInTray { return false, "Autostart shortcut does not start in tray" } return false, "Autostart shortcut starts in tray while setting is off" } 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, arguments 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="+arguments, "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 removeIfExists(path string) error { err := os.Remove(path) if err == nil || os.IsNotExist(err) { return nil } return err } 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) }