36b3af2e3d
PR Check / Code Quality: Format (push) Failing after 1s
PR Check / Code Quality: Lint (darwin) (push) Failing after 0s
PR Check / Code Quality: Lint (freebsd) (push) Failing after 1s
PR Check / Code Quality: Lint (windows) (push) Failing after 1s
PR Check / Code Quality: Lint (linux) (push) Failing after 1s
PR Check / Security: Vulnerability Scan (push) Failing after 0s
Update Documentation / update-docs (push) Failing after 2s
PR Check / Code Quality: Vendor (push) Failing after 1s
PR Check / Code Quality: Coverage (push) Failing after 0s
PR Check / Tests: Unit (macos-latest) (push) Has been cancelled
PR Check / Tests: Unit (ubuntu-24.04) (push) Has been cancelled
PR Check / Tests: Unit (ubuntu-24.04-arm) (push) Has been cancelled
PR Check / Tests: Unit (windows-latest) (push) Has been cancelled
513 lines
12 KiB
Go
513 lines
12 KiB
Go
//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/<uid>
|
|
|
|
// 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 <pid> 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"
|
|
}
|
|
}
|