//go:build darwin package launchd import ( "bytes" "encoding/xml" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" ) // LaunchdInfo contains parsed information about a launchd service type LaunchdInfo struct { Label string Comment string PlistPath string Domain string // user, system, or gui/ // Triggers RunAtLoad bool KeepAlive bool StartInterval int // seconds StartCalendarInterval string // human-readable schedule WatchPaths []string QueueDirectories []string // Program info Program string ProgramArguments []string } // plistDict represents a plist dictionary for XML parsing type plistDict struct { Keys []string Values []plistValue } type plistValue struct { String string Integer int Bool *bool Array []string Dict *plistDict } // plist search paths in order of precedence var plistSearchPaths = []string{ "~/Library/LaunchAgents", "/Library/LaunchAgents", "/Library/LaunchDaemons", "/System/Library/LaunchAgents", "/System/Library/LaunchDaemons", } // GetServiceLabel uses launchctl blame to get the service label for a PID func GetServiceLabel(pid int) (string, string, error) { // launchctl blame returns the service that started the process out, err := exec.Command("launchctl", "blame", strconv.Itoa(pid)).Output() if err != nil { return "", "", fmt.Errorf("launchctl blame failed: %w", err) } // Output format varies: // - "system/com.apple.example" or "gui/501/com.example.app" (real service) // - "speculative", "non-ipc demand", "launch job demand", "ipc (mach)" (blame reasons) line := strings.TrimSpace(string(out)) if line == "" { return "", "", fmt.Errorf("no service label found for pid %d", pid) } // Check if this is a real service path (contains "/" and starts with domain) if !strings.Contains(line, "/") { // This is a blame reason, not a service label // Try to find the service by querying launchctl list label, domain := findServiceByPID(pid) if label != "" { return label, domain, nil } return "", "", fmt.Errorf("process not managed by a named launchd service: %s", line) } // Parse domain and label from service path parts := strings.SplitN(line, "/", 2) if len(parts) < 2 { return line, "", nil } domain := parts[0] label := parts[1] // Handle gui/501/label format if domain == "gui" { subParts := strings.SplitN(label, "/", 2) if len(subParts) == 2 { domain = "gui/" + subParts[0] label = subParts[1] } } return label, domain, nil } // findServiceByPID queries launchctl list to find a service matching the given PID func findServiceByPID(pid int) (string, string) { // launchctl list shows: PID Status Label out, err := exec.Command("launchctl", "list").Output() if err != nil { return "", "" } pidStr := strconv.Itoa(pid) for line := range strings.Lines(string(out)) { fields := strings.Fields(line) if len(fields) >= 3 && fields[0] == pidStr { label := fields[2] // Determine domain based on label prefix domain := "user" if strings.HasPrefix(label, "com.apple.") { domain = "system" } return label, domain } } return "", "" } // FindPlistPath searches for the plist file for a given service label func FindPlistPath(label string) string { homeDir, _ := os.UserHomeDir() for _, searchPath := range plistSearchPaths { path := searchPath if strings.HasPrefix(path, "~") { path = filepath.Join(homeDir, path[1:]) } plistPath := filepath.Join(path, label+".plist") if _, err := os.Stat(plistPath); err == nil { return plistPath } } return "" } // ParsePlist reads and parses a launchd plist file func ParsePlist(path string) (*LaunchdInfo, error) { // Use plutil to convert to XML (handles binary plists) out, err := exec.Command("plutil", "-convert", "xml1", "-o", "-", path).Output() if err != nil { return nil, fmt.Errorf("failed to convert plist: %w", err) } info := &LaunchdInfo{ PlistPath: path, } // Parse the XML plist if err := parsePlistXML(out, info); err != nil { return nil, err } return info, nil } // parsePlistXML parses XML plist data into LaunchdInfo func parsePlistXML(data []byte, info *LaunchdInfo) error { decoder := xml.NewDecoder(bytes.NewReader(data)) var currentKey string var dictDepth int // Track dict nesting depth (1 = root dict) for { token, err := decoder.Token() if err != nil { break } switch t := token.(type) { case xml.StartElement: switch t.Name.Local { case "dict": dictDepth++ if dictDepth == 2 && currentKey == "StartCalendarInterval" { cal := parseCalendarDict(decoder) info.StartCalendarInterval = formatCalendarInterval(cal) currentKey = "" continue } // Skip other nested dicts by clearing currentKey if dictDepth > 1 { currentKey = "" } case "key": // Only capture keys at root dict level if dictDepth == 1 { var key string decoder.DecodeElement(&key, &t) currentKey = key } case "string": if dictDepth == 1 && currentKey != "" { var val string decoder.DecodeElement(&val, &t) handleStringValue(info, currentKey, val) currentKey = "" } case "integer": if dictDepth == 1 && currentKey != "" { var val string decoder.DecodeElement(&val, &t) if i, err := strconv.Atoi(val); err == nil { handleIntValue(info, currentKey, i) } currentKey = "" } case "true": if dictDepth == 1 && currentKey != "" { handleBoolValue(info, currentKey, true) currentKey = "" } case "false": if dictDepth == 1 && currentKey != "" { handleBoolValue(info, currentKey, false) currentKey = "" } case "array": if dictDepth == 1 && currentKey == "StartCalendarInterval" { intervals := parseCalendarArray(decoder) info.StartCalendarInterval = intervals currentKey = "" } else if dictDepth == 1 && currentKey != "" { arr := parseArray(decoder) handleArrayValue(info, currentKey, arr) currentKey = "" } } case xml.EndElement: if t.Name.Local == "dict" { dictDepth-- } } } return nil } func parseArray(decoder *xml.Decoder) []string { var result []string depth := 1 for depth > 0 { token, err := decoder.Token() if err != nil { break } switch t := token.(type) { case xml.StartElement: if t.Name.Local == "array" { depth++ } else if t.Name.Local == "string" { var val string decoder.DecodeElement(&val, &t) result = append(result, val) } case xml.EndElement: if t.Name.Local == "array" { depth-- } } } return result } // parseCalendarDict parses a single StartCalendarInterval dict into key-value pairs. func parseCalendarDict(decoder *xml.Decoder) map[string]int { result := make(map[string]int) var currentKey string for { token, err := decoder.Token() if err != nil { break } switch t := token.(type) { case xml.StartElement: switch t.Name.Local { case "key": var key string decoder.DecodeElement(&key, &t) currentKey = key case "integer": var val string decoder.DecodeElement(&val, &t) if currentKey != "" { if i, err := strconv.Atoi(val); err == nil { result[currentKey] = i } currentKey = "" } } case xml.EndElement: if t.Name.Local == "dict" { return result } } } return result } // parseCalendarArray parses an array of StartCalendarInterval dicts. func parseCalendarArray(decoder *xml.Decoder) string { var intervals []string depth := 1 for depth > 0 { token, err := decoder.Token() if err != nil { break } switch t := token.(type) { case xml.StartElement: if t.Name.Local == "array" { depth++ } else if t.Name.Local == "dict" { cal := parseCalendarDict(decoder) if s := formatCalendarInterval(cal); s != "" { intervals = append(intervals, s) } } case xml.EndElement: if t.Name.Local == "array" { depth-- } } } if len(intervals) == 0 { return "" } return strings.Join(intervals, "; ") } // formatCalendarInterval converts a calendar dict into a human-readable string. // Keys: Month, Day, Weekday (0=Sun), Hour, Minute func formatCalendarInterval(cal map[string]int) string { if len(cal) == 0 { return "" } weekdays := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} var parts []string if w, ok := cal["Weekday"]; ok && w >= 0 && w < len(weekdays) { parts = append(parts, weekdays[w]) } if m, ok := cal["Month"]; ok { parts = append(parts, fmt.Sprintf("month %d", m)) } if d, ok := cal["Day"]; ok { parts = append(parts, fmt.Sprintf("day %d", d)) } h, hasHour := cal["Hour"] min, hasMin := cal["Minute"] switch { case hasHour && hasMin: parts = append(parts, fmt.Sprintf("at %02d:%02d", h, min)) case hasHour: parts = append(parts, fmt.Sprintf("at %02d:00", h)) case hasMin: parts = append(parts, fmt.Sprintf("at *:%02d", min)) } if len(parts) == 0 { return "" } return strings.Join(parts, " ") } func handleStringValue(info *LaunchdInfo, key, val string) { switch key { case "Label": info.Label = val case "Comment": info.Comment = val case "Program": info.Program = val } } func handleIntValue(info *LaunchdInfo, key string, val int) { switch key { case "StartInterval": info.StartInterval = val } } func handleBoolValue(info *LaunchdInfo, key string, val bool) { switch key { case "RunAtLoad": info.RunAtLoad = val case "KeepAlive": info.KeepAlive = val } } func handleArrayValue(info *LaunchdInfo, key string, val []string) { switch key { case "ProgramArguments": info.ProgramArguments = val case "WatchPaths": info.WatchPaths = val case "QueueDirectories": info.QueueDirectories = val } } // GetLaunchdInfo retrieves full launchd information for a process func GetLaunchdInfo(pid int) (*LaunchdInfo, error) { label, domain, err := GetServiceLabel(pid) if err != nil { return nil, err } plistPath := FindPlistPath(label) if plistPath == "" { // Return basic info even if we can't find the plist return &LaunchdInfo{ Label: label, Domain: domain, }, nil } info, err := ParsePlist(plistPath) if err != nil { // Return basic info on parse error return &LaunchdInfo{ Label: label, Domain: domain, PlistPath: plistPath, }, nil } info.Domain = domain return info, nil } // FormatTriggers returns a human-readable description of what triggers the service func (info *LaunchdInfo) FormatTriggers() []string { var triggers []string if info.RunAtLoad { triggers = append(triggers, "RunAtLoad (starts at login/boot)") } if info.StartInterval > 0 { triggers = append(triggers, fmt.Sprintf("StartInterval (every %s)", formatDuration(info.StartInterval))) } if info.StartCalendarInterval != "" { triggers = append(triggers, fmt.Sprintf("StartCalendarInterval (%s)", info.StartCalendarInterval)) } if len(info.WatchPaths) > 0 { for _, p := range info.WatchPaths { triggers = append(triggers, fmt.Sprintf("WatchPaths: %s", p)) } } if len(info.QueueDirectories) > 0 { for _, p := range info.QueueDirectories { triggers = append(triggers, fmt.Sprintf("QueueDirectories: %s", p)) } } return triggers } func formatDuration(seconds int) string { if seconds < 60 { return fmt.Sprintf("%ds", seconds) } if seconds < 3600 { return fmt.Sprintf("%dm", seconds/60) } if seconds < 86400 { return fmt.Sprintf("%dh", seconds/3600) } return fmt.Sprintf("%dd", seconds/86400) } // DomainDescription returns a human-readable description of the domain func (info *LaunchdInfo) DomainDescription() string { switch { case info.Domain == "system": return "Launch Daemon" case strings.HasPrefix(info.Domain, "gui/"): return "Launch Agent" case info.Domain == "user": return "Launch Agent" default: return "launchd service" } }