LaunchAgent used KeepAlive=true, so launchd restarted cc-connect after every process exit, including graceful SIGTERM shutdown. That looked like "stop then immediate restart" and could make tooling that waits for the Unix API socket to disappear hang if the service respawned quickly. Use KeepAlive with SuccessfulExit=true so only unclean exits are restarted, matching systemd Restart=on-failure behavior. Existing installs: re-run `cc-connect daemon install --force` to refresh the plist. Fixes #153 Made-with: Cursor Co-authored-by: Claude <noreply@anthropic.com>
201 lines
5.2 KiB
Go
201 lines
5.2 KiB
Go
//go:build darwin
|
|
|
|
package daemon
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
launchdLabel = "com.cc-connect.service"
|
|
)
|
|
|
|
type launchdManager struct{}
|
|
|
|
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)
|
|
_ = exec.Command("launchctl", "bootout", fmt.Sprintf("gui/%d/%s", os.Getuid(), launchdLabel)).Run()
|
|
|
|
plist := buildPlist(cfg)
|
|
if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil {
|
|
return fmt.Errorf("write plist: %w", err)
|
|
}
|
|
|
|
domain := fmt.Sprintf("gui/%d", os.Getuid())
|
|
if out, err := runLaunchctl("bootstrap", domain, plistPath); err != nil {
|
|
return fmt.Errorf("launchctl bootstrap: %s (%w)", out, err)
|
|
}
|
|
|
|
if _, err := runLaunchctl("kickstart", "-kp", fmt.Sprintf("%s/%s", domain, launchdLabel)); err != nil {
|
|
return fmt.Errorf("launchctl kickstart: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *launchdManager) Uninstall() error {
|
|
domain := fmt.Sprintf("gui/%d", os.Getuid())
|
|
_, _ = runLaunchctl("bootout", fmt.Sprintf("%s/%s", domain, launchdLabel))
|
|
|
|
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 {
|
|
domain := fmt.Sprintf("gui/%d", os.Getuid())
|
|
plistPath := launchdPlistPath()
|
|
var out string
|
|
if _, err := runLaunchctl("bootstrap", domain, plistPath); err != nil {
|
|
// already bootstrapped — try kickstart
|
|
out, err = runLaunchctl("kickstart", "-kp", fmt.Sprintf("%s/%s", domain, launchdLabel))
|
|
if err != nil {
|
|
return fmt.Errorf("start: %s (%w)", out, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (*launchdManager) Stop() error {
|
|
domain := fmt.Sprintf("gui/%d", os.Getuid())
|
|
out, err := runLaunchctl("bootout", fmt.Sprintf("%s/%s", domain, launchdLabel))
|
|
if err != nil {
|
|
return fmt.Errorf("stop: %s (%w)", out, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (*launchdManager) Restart() error {
|
|
domain := fmt.Sprintf("gui/%d", os.Getuid())
|
|
target := fmt.Sprintf("%s/%s", domain, launchdLabel)
|
|
_, _ = runLaunchctl("bootout", target)
|
|
|
|
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
|
|
|
|
domain := fmt.Sprintf("gui/%d", os.Getuid())
|
|
target := fmt.Sprintf("%s/%s", domain, launchdLabel)
|
|
out, _ := runLaunchctl("print", target)
|
|
|
|
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 buildPlist(cfg Config) string {
|
|
envPATH := cfg.EnvPATH
|
|
if envPATH == "" {
|
|
envPATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"
|
|
}
|
|
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>%s</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>%s</string>
|
|
</array>
|
|
<key>WorkingDirectory</key>
|
|
<string>%s</string>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>KeepAlive</key>
|
|
<dict>
|
|
<key>SuccessfulExit</key>
|
|
<true/>
|
|
</dict>
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
<key>CC_LOG_FILE</key>
|
|
<string>%s</string>
|
|
<key>CC_LOG_MAX_SIZE</key>
|
|
<string>%d</string>
|
|
<key>PATH</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
<key>StandardOutPath</key>
|
|
<string>/dev/null</string>
|
|
<key>StandardErrorPath</key>
|
|
<string>/dev/null</string>
|
|
</dict>
|
|
</plist>
|
|
`, launchdLabel, cfg.BinaryPath, cfg.WorkDir, cfg.LogFile, cfg.LogMaxSize, envPATH)
|
|
}
|
|
|
|
func runLaunchctl(args ...string) (string, error) {
|
|
cmd := exec.Command("launchctl", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|