From 8c167b8f6f8421c27b936ae96f8746a868aa1661 Mon Sep 17 00:00:00 2001 From: zap Date: Thu, 1 Oct 2026 19:05:01 +0800 Subject: [PATCH] fix(autostart): explicitly load LaunchAgent via launchctl on macOS macOS 27 no longer auto-loads LaunchAgents placed in ~/Library/LaunchAgents/ and refuses plists carrying the com.apple.quarantine xattr. Enable() now strips quarantine and registers the agent with (preferred over which returns spurious EIO on macOS 26+). Disable() unloads via before removing the plist. Fixes #203 --- internal/autostart/autostart_darwin.go | 44 +++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/internal/autostart/autostart_darwin.go b/internal/autostart/autostart_darwin.go index 520c945e..d597c7d0 100644 --- a/internal/autostart/autostart_darwin.go +++ b/internal/autostart/autostart_darwin.go @@ -3,6 +3,7 @@ package autostart import ( "fmt" "os" + "os/exec" "path/filepath" "strings" ) @@ -78,7 +79,11 @@ func Enable() error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - return os.WriteFile(path, []byte(plistContent(binary)), 0o644) + if err := os.WriteFile(path, []byte(plistContent(binary)), 0o644); err != nil { + return err + } + removeQuarantine(path) + return loadLaunchAgent(path) } // Disable removes the LaunchAgent plist. @@ -87,6 +92,7 @@ func Disable() error { if err != nil { return err } + unloadLaunchAgent(path, plistLabel) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } @@ -111,3 +117,39 @@ func PlistContainsBinary() (bool, error) { binary, _ = filepath.EvalSymlinks(binary) return strings.Contains(string(data), binary), nil } + +// removeQuarantine strips the com.apple.quarantine extended attribute from +// the plist. macOS 27's launchd refuses to load quarantined plists, and a +// plist created by a downloaded binary can inherit the attribute. +func removeQuarantine(path string) { + _ = exec.Command("xattr", "-d", "com.apple.quarantine", path).Run() +} + +// loadLaunchAgent registers the LaunchAgent with launchd. It prefers the +// classic `launchctl load -w` (the -w flag persists across logins) because +// `launchctl bootstrap` returns spurious EIO errors on macOS 26+. If the +// agent is already loaded, it is unloaded first and then reloaded. +func loadLaunchAgent(path string) error { + if err := exec.Command("launchctl", "load", "-w", path).Run(); err == nil { + return nil + } + // Already loaded — unload then reload. + _ = exec.Command("launchctl", "unload", path).Run() + if err := exec.Command("launchctl", "load", "-w", path).Run(); err == nil { + return nil + } + // Last-resort fallback to the modern API. + uid := os.Getuid() + return exec.Command("launchctl", "bootstrap", fmt.Sprintf("gui/%d", uid), path).Run() +} + +// unloadLaunchAgent deregisters the LaunchAgent from launchd. It prefers +// `launchctl unload -w` and falls back to `launchctl bootout`. Errors are +// ignored when the agent is not currently loaded. +func unloadLaunchAgent(path, label string) { + if err := exec.Command("launchctl", "unload", "-w", path).Run(); err == nil { + return + } + uid := os.Getuid() + _ = exec.Command("launchctl", "bootout", fmt.Sprintf("gui/%d/%s", uid, label)).Run() +}