Files
gosentry/src/platform/autostart/autostart_windows.go
T
mix ca2a8c8aa7 fix: accept zero retention limits, retire Store() for typed accessors
Phase 8 (PROJECT_REVIEW_PLAN.md 8.1): 0 in MaxLogFiles/MaxLogAgeDays now
means "keep everything" end to end. runner.CleanupLogs already treated
<= 0 as disabled; validateConfig, the Settings form, and
loadOrCreateConfig's backfill were the only things making that state
unreachable.

Phase 9 (1.1, rolling up 1.2, 1.3, 7.3): added Service.Config() and
Service.Paths(), copying under mu, and converted every UI site that read
Service state through the raw *storage.Store returned by Store() (now
removed). jobs_view's pause control is now driven by refreshView reading
svc.Config().Paused on every event instead of only mirroring its own tap
handler, which makes it an actual consumer of SchedulerStateChanged.
mainwindow's event listener is a real type switch, and events.go's doc
comment no longer claims a compiler exhaustiveness check Go doesn't have.
Unexported the redundant SetAutostart/AutostartStatus package functions
in platform/autostart now that only the Manager methods are used outside
the package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 22:15:19 +03:00

188 lines
7.2 KiB
Go

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