//go:build darwin package daemon import ( "encoding/xml" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" ) const ( launchdLabel = "com.cc-connect.service" ) var runLaunchctl = func(args ...string) (string, error) { cmd := exec.Command("launchctl", args...) out, err := cmd.CombinedOutput() return strings.TrimSpace(string(out)), err } type launchdManager struct{} // CheckLinger always returns true on macOS: launchd user agents persist // independently of login sessions, so no "linger" warning is needed. func CheckLinger() (enabled bool, user string) { return true, "" } func newPlatformManager() (Manager, error) { return &launchdManager{}, nil } func (*launchdManager) Platform() string { return "launchd" } func (m *launchdManager) Install(cfg Config) error { plistPath := launchdPlistPath() if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil { return fmt.Errorf("create LaunchAgents dir: %w", err) } if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0755); err != nil { return fmt.Errorf("create log dir: %w", err) } // Unload existing service first (ignore errors) so we do not leave a stale // job behind when switching between GUI and headless sessions. bootoutLaunchdTargets() plist := buildPlist(cfg) if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil { return fmt.Errorf("write plist: %w", err) } domain := preferredLaunchdDomain() if out, err := runLaunchctl("bootstrap", domain, plistPath); err != nil { return fmt.Errorf("launchctl bootstrap: %s (%w)", out, err) } if _, err := runLaunchctl("kickstart", "-kp", launchdTarget(domain)); err != nil { return fmt.Errorf("launchctl kickstart: %w", err) } return nil } func (m *launchdManager) Uninstall() error { bootoutLaunchdTargets() plistPath := launchdPlistPath() if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove plist: %w", err) } return nil } func (*launchdManager) Start() error { if _, target, _, ok := loadedLaunchdTarget(); ok { out, err := runLaunchctl("kickstart", "-kp", target) if err != nil { return fmt.Errorf("start: %s (%w)", out, err) } return nil } domain := preferredLaunchdDomain() plistPath := launchdPlistPath() var out string if _, err := runLaunchctl("bootstrap", domain, plistPath); err != nil { // already bootstrapped — try kickstart out, err = runLaunchctl("kickstart", "-kp", launchdTarget(domain)) if err != nil { return fmt.Errorf("start: %s (%w)", out, err) } } return nil } func (*launchdManager) Stop() error { var lastOut string var lastErr error for _, target := range launchdTargets() { out, err := runLaunchctl("bootout", target) if err == nil { return nil } lastOut = out lastErr = err } if lastErr != nil { return fmt.Errorf("stop: %s (%w)", lastOut, lastErr) } return nil } func (*launchdManager) Restart() error { domain := preferredLaunchdDomain() if loadedDomain, _, _, ok := loadedLaunchdTarget(); ok && domain != launchdGUIDomain() { domain = loadedDomain } target := launchdTarget(domain) bootoutLaunchdTargets() plistPath := launchdPlistPath() // launchd bootout is asynchronous; retry bootstrap with backoff // to avoid "Bootstrap failed: 5" race condition. var out string var err error for i := 0; i < 3; i++ { if i > 0 { time.Sleep(500 * time.Millisecond) } out, err = runLaunchctl("bootstrap", domain, plistPath) if err == nil { break } } if err != nil { return fmt.Errorf("restart: %s (%w)", out, err) } if _, err := runLaunchctl("kickstart", "-kp", target); err != nil { return fmt.Errorf("restart kickstart: %w", err) } return nil } func (*launchdManager) Status() (*Status, error) { st := &Status{Platform: "launchd"} plistPath := launchdPlistPath() if _, err := os.Stat(plistPath); err != nil { return st, nil } st.Installed = true _, _, out, ok := loadedLaunchdTarget() if !ok { return st, nil } for _, line := range strings.Split(out, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "pid = ") { if pid, err := strconv.Atoi(strings.TrimPrefix(trimmed, "pid = ")); err == nil && pid > 0 { st.PID = pid st.Running = true } } if strings.Contains(trimmed, "state = running") { st.Running = true } } return st, nil } // ── helpers ───────────────────────────────────────────────── func launchdPlistPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist") } func launchdUserDomain() string { return fmt.Sprintf("user/%d", os.Getuid()) } func launchdGUIDomain() string { return fmt.Sprintf("gui/%d", os.Getuid()) } func preferredLaunchdDomain() string { guiDomain := launchdGUIDomain() if _, err := runLaunchctl("print", guiDomain); err == nil { return guiDomain } return launchdUserDomain() } func launchdDomains() []string { preferred := preferredLaunchdDomain() guiDomain := launchdGUIDomain() userDomain := launchdUserDomain() if preferred == guiDomain { return []string{guiDomain, userDomain} } return []string{userDomain, guiDomain} } func launchdTarget(domain string) string { return fmt.Sprintf("%s/%s", domain, launchdLabel) } func launchdTargets() []string { domains := launchdDomains() targets := make([]string, 0, len(domains)) for _, domain := range domains { targets = append(targets, launchdTarget(domain)) } return targets } func loadedLaunchdTarget() (string, string, string, bool) { for _, domain := range launchdDomains() { target := launchdTarget(domain) out, err := runLaunchctl("print", target) if err == nil { return domain, target, out, true } } return "", "", "", false } func bootoutLaunchdTargets() { for _, target := range launchdTargets() { _, _ = runLaunchctl("bootout", target) } } func buildPlist(cfg Config) string { envPATH := cfg.EnvPATH if envPATH == "" { envPATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin" } // User-supplied paths can legitimately contain XML-special characters // ('&', '<', '>', '"', '\''). Without escaping, `launchctl bootstrap` // rejects the plist with a parse error and daemon install fails. The // label is a hard-coded constant so it does not need escaping; LogMaxSize // is an int. return fmt.Sprintf(` Label %s ProgramArguments %s WorkingDirectory %s RunAtLoad LimitLoadToSessionType Aqua Background KeepAlive SuccessfulExit EnvironmentVariables CC_LOG_FILE %s CC_LOG_MAX_SIZE %d PATH %s StandardOutPath /dev/null StandardErrorPath /dev/null `, launchdLabel, xmlEscape(cfg.BinaryPath), xmlEscape(cfg.WorkDir), xmlEscape(cfg.LogFile), cfg.LogMaxSize, xmlEscape(envPATH)) } // xmlEscape escapes the five XML-reserved characters in a string so it can // be safely embedded inside a plist element. func xmlEscape(s string) string { var b strings.Builder if err := xml.EscapeText(&b, []byte(s)); err != nil { // xml.EscapeText only fails when the underlying writer fails; a // strings.Builder never returns a write error. Fall back to the // raw value defensively rather than panicking. return s } return b.String() }