Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:22:06 +08:00

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"
}
}