194 lines
4.9 KiB
Go
194 lines
4.9 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)
|
|
}
|
|
|
|
runLaunchctl("kickstart", "-kp", fmt.Sprintf("%s/%s", domain, launchdLabel))
|
|
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()
|
|
out, err := runLaunchctl("bootstrap", domain, plistPath)
|
|
if 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)
|
|
}
|
|
runLaunchctl("kickstart", "-kp", target)
|
|
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>
|
|
<true/>
|
|
<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
|
|
}
|