diff --git a/README.md b/README.md index 47a52e06..2c85579c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ A universal command-line tool for managing iOS and Android devices, simulators, - **App Management**: Launch, terminate, install, uninstall, list, and get foreground apps - **Filesystem**: Push, pull, list, mkdir, and rm files on-device or in app containers (Android, iOS Simulator) - **Crash Reports**: List and fetch crash reports from iOS and Android devices +- **Device Logs**: Stream real-time device logs with filtering from iOS and Android devices - **Webview Inspection**: List, navigate, query DOM, and evaluate JavaScript in embedded webviews ### 🎯 Platform Support @@ -425,6 +426,33 @@ Example output for `crashes list`: **Note**: On iOS real devices, crash reports are fetched via the Apple crashreport service. On iOS simulators, they are read from `~/Library/Logs/DiagnosticReports/`. On Android, crashes are parsed from `adb logcat -b crash`. +### Device Logs 📋 + +```bash +# Stream logs from a device (Ctrl+C to stop) +mobilecli device logs --device + +# Stop after 100 entries +mobilecli device logs --device --limit 100 + +# Filter by field (exact match) +mobilecli device logs --filter process=SpringBoard +mobilecli device logs --filter tag=ActivityManager + +# Exclude by field +mobilecli device logs --filter process!=SpringBoard + +# Combine filters (AND logic) +mobilecli device logs --filter level=Error --filter process!=SpringBoard +``` + +Supported filter keys: `pid`, `process`, `tag`, `level`, `subsystem`, `category`, `message` + +Each log entry is printed as a JSON line: +```json +{"timestamp":"2026-04-15 12:17:14.224451+0300","message":"Start proc...","level":"Default","subsystem":"com.apple.UIKit","category":"EventDispatch","pid":54052,"process":"SpringBoard"} +``` + ### Remote Devices ☁️ ```bash diff --git a/cli/logs.go b/cli/logs.go new file mode 100644 index 00000000..386d0089 --- /dev/null +++ b/cli/logs.go @@ -0,0 +1,57 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/mobile-next/mobilecli/commands" + "github.com/spf13/cobra" +) + +var logsLimit int +var logsFilters []string + +var deviceLogsCmd = &cobra.Command{ + Use: "logs", + Short: "Stream device logs", + Long: `Streams real-time logs from a device. Press Ctrl+C to stop. + +Filters use key=value (include) or key!=value (exclude) syntax. +Multiple --filter flags are ANDed together. + +Supported keys: pid, process, tag, level, subsystem, category, message + +Examples: + mobilecli device logs --filter tag=ActivityManager + mobilecli device logs --filter process!=SpringBoard + mobilecli device logs --filter level=Error --filter process=backboardd`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + filters, err := commands.ParseLogFilters(logsFilters) + if err != nil { + return err + } + response := commands.LogsCommand(ctx, commands.LogsRequest{ + DeviceID: deviceId, + Limit: logsLimit, + Filters: filters, + }) + if response.Status == "error" { + printJson(response) + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +func init() { + deviceCmd.AddCommand(deviceLogsCmd) + deviceLogsCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to stream logs from") + deviceLogsCmd.Flags().IntVar(&logsLimit, "limit", 0, "Stop after N log entries (0 = unlimited)") + deviceLogsCmd.Flags().StringArrayVar(&logsFilters, "filter", nil, "Filter logs (key=value or key!=value, repeatable)") +} diff --git a/cli/root.go b/cli/root.go index 03e03afb..4a74b214 100644 --- a/cli/root.go +++ b/cli/root.go @@ -169,6 +169,19 @@ FILESYSTEM: # Remove a file or directory mobilecli fs rm --device -r /sdcard/myfolder +DEVICE LOGS: + # Stream logs from a device (Ctrl+C to stop) + mobilecli device logs --device + + # Stop after N entries + mobilecli device logs --device --limit 100 + + # Filter (key=value to include, key!=value to exclude; --filter is repeatable) + mobilecli device logs --device --filter process=SpringBoard + mobilecli device logs --device --filter level=Error --filter process!=SpringBoard + + # Filter keys: pid, process, tag, level, subsystem, category, message + UTILITIES: # Open a URL or deep link mobilecli url --device https://example.com diff --git a/commands/logs.go b/commands/logs.go new file mode 100644 index 00000000..2938be1e --- /dev/null +++ b/commands/logs.go @@ -0,0 +1,130 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/mobile-next/mobilecli/devices" +) + +type LogFilter struct { + Key string + Value string + Negate bool +} + +type LogsRequest struct { + DeviceID string + Limit int + Filters []LogFilter +} + +// ParseLogFilters parses filter strings like "key=value" or "key!=value" +func ParseLogFilters(raw []string) ([]LogFilter, error) { + var filters []LogFilter + for _, s := range raw { + f, err := parseOneFilter(s) + if err != nil { + return nil, err + } + filters = append(filters, f) + } + return filters, nil +} + +var validFilterKeys = map[string]bool{ + "pid": true, "process": true, "tag": true, + "level": true, "subsystem": true, "category": true, + "message": true, +} + +func parseOneFilter(s string) (LogFilter, error) { + // try != first (before =) + if idx := strings.Index(s, "!="); idx > 0 { + key := s[:idx] + if !validFilterKeys[key] { + return LogFilter{}, fmt.Errorf("unknown filter key %q (valid: pid, process, tag, level, subsystem, category, message)", key) + } + return LogFilter{Key: key, Value: s[idx+2:], Negate: true}, nil + } + if idx := strings.Index(s, "="); idx > 0 { + key := s[:idx] + if !validFilterKeys[key] { + return LogFilter{}, fmt.Errorf("unknown filter key %q (valid: pid, process, tag, level, subsystem, category, message)", key) + } + return LogFilter{Key: key, Value: s[idx+1:]}, nil + } + return LogFilter{}, fmt.Errorf("invalid filter %q (expected key=value or key!=value)", s) +} + +func getFieldValue(entry devices.LogEntry, key string) string { + switch key { + case "pid": + return strconv.Itoa(entry.PID) + case "process": + return entry.Process + case "tag": + return entry.Tag + case "level": + return entry.Level + case "subsystem": + return entry.Subsystem + case "category": + return entry.Category + case "message": + return entry.Message + default: + return "" + } +} + +func matchesFilters(entry devices.LogEntry, filters []LogFilter) bool { + for _, f := range filters { + fieldValue := getFieldValue(entry, f.Key) + match := fieldValue == f.Value + if f.Negate { + match = !match + } + if !match { + return false + } + } + return true +} + +func LogsCommand(ctx context.Context, req LogsRequest) *CommandResponse { + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %w", err)) + } + + encoder := json.NewEncoder(os.Stdout) + count := 0 + + emit := func(entry devices.LogEntry) bool { + if err := encoder.Encode(entry); err != nil { + return false + } + count++ + if req.Limit > 0 && count >= req.Limit { + return false + } + return true + } + + err = device.StreamLogs(ctx, func(entry devices.LogEntry) bool { + if !matchesFilters(entry, req.Filters) { + return true + } + return emit(entry) + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("error streaming logs: %w", err)) + } + + return NewSuccessResponse("done") +} diff --git a/devices/android.go b/devices/android.go index 1ece8d19..4cc6c8e8 100644 --- a/devices/android.go +++ b/devices/android.go @@ -1,6 +1,8 @@ package devices import ( + "bufio" + "bytes" "context" "encoding/base64" "encoding/json" @@ -14,6 +16,7 @@ import ( "runtime" "strconv" "strings" + "sync" "syscall" "time" @@ -1722,3 +1725,100 @@ func (d *AndroidDevice) GetCrashReport(id string) ([]byte, error) { } return []byte(content), nil } + +type androidPidCache struct { + mu sync.Mutex + names map[int]string +} + +func (c *androidPidCache) resolveProcessNameByPid(d *AndroidDevice, pid int) string { + c.mu.Lock() + name, ok := c.names[pid] + c.mu.Unlock() + if ok { + return name + } + + fmt.Fprintf(os.Stderr, "resolving pid %d\n", pid) + out, err := d.runAdbCommand("shell", "cat", fmt.Sprintf("/proc/%d/cmdline", pid)) + if err != nil || len(out) == 0 { + return "" + } + // cmdline is null-delimited; first entry is the executable path + first, _, _ := bytes.Cut(out, []byte{0}) + name = processNameFromPath(string(first)) + + c.mu.Lock() + c.names[pid] = name + c.mu.Unlock() + return name +} + +var logcatLevelMap = map[string]string{ + "V": "Verbose", + "D": "Debug", + "I": "Info", + "W": "Warning", + "E": "Error", + "F": "Fatal", + "A": "Assert", +} + +func (d *AndroidDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { + pidCache := &androidPidCache{names: make(map[int]string)} + + args := []string{"logcat", "-v", "threadtime,year", "-T", "1"} + cmdArgs := append([]string{"-s", d.getAdbIdentifier()}, args...) + cmd := exec.CommandContext(ctx, getAdbPath(), cmdArgs...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start logcat: %w", err) + } + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + stoppedByCaller := false + for scanner.Scan() { + line := scanner.Text() + + parsed := parseLogcatLine(line) + if parsed == nil { + continue + } + + pid, _ := strconv.Atoi(parsed.PID) + level := logcatLevelMap[parsed.Level] + if level == "" { + level = parsed.Level + } + + entry := LogEntry{ + Timestamp: parsed.Date + " " + parsed.Time, + PID: pid, + Level: level, + Tag: parsed.Tag, + Message: parsed.Message, + Process: pidCache.resolveProcessNameByPid(d, pid), + } + + if !onLog(entry) { + _ = cmd.Process.Kill() + stoppedByCaller = true + break + } + } + + _ = cmd.Wait() + if stoppedByCaller || ctx.Err() != nil { + return nil + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("logcat read error: %w", err) + } + return nil +} diff --git a/devices/common.go b/devices/common.go index e87ad19b..2f6afa94 100644 --- a/devices/common.go +++ b/devices/common.go @@ -1,10 +1,12 @@ package devices import ( + "context" "encoding/json" "fmt" "os" "regexp" + "strings" "time" "github.com/mobile-next/mobilecli/devices/wda" @@ -12,6 +14,26 @@ import ( "github.com/mobile-next/mobilecli/utils" ) +// LogEntry represents a single parsed log entry from a device +type LogEntry struct { + Timestamp string `json:"timestamp"` + Message string `json:"message"` + Level string `json:"level"` + Subsystem string `json:"subsystem,omitempty"` + Category string `json:"category,omitempty"` + PID int `json:"pid"` + Process string `json:"process,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// processNameFromPath extracts the binary name from a full image path +func processNameFromPath(path string) string { + if idx := strings.LastIndex(path, "/"); idx != -1 { + return path[idx+1:] + } + return path +} + type CrashReport struct { ProcessName string `json:"processName"` Timestamp string `json:"timestamp"` @@ -146,6 +168,7 @@ type ControllableDevice interface { SetOrientation(orientation string) error ListCrashReports() ([]CrashReport, error) GetCrashReport(id string) ([]byte, error) + StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error PushFile(localPath, remotePath string) error PullFile(remotePath, localPath string) error diff --git a/devices/ios.go b/devices/ios.go index 19c9e0fc..bce3b1a5 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -19,6 +19,7 @@ import ( "github.com/danielpaulus/go-ios/ios/crashreport" "github.com/danielpaulus/go-ios/ios/diagnostics" "github.com/danielpaulus/go-ios/ios/installationproxy" + "github.com/danielpaulus/go-ios/ios/ostrace" "github.com/danielpaulus/go-ios/ios/instruments" "github.com/danielpaulus/go-ios/ios/testmanagerd" "github.com/danielpaulus/go-ios/ios/tunnel" @@ -1635,3 +1636,84 @@ func (d *IOSDevice) GetCrashReport(id string) ([]byte, error) { return content, nil } + +func (d *IOSDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { + // ensure tunnel is running for iOS 17+ + err := d.startTunnel() + if err != nil { + return fmt.Errorf("failed to start tunnel: %w", err) + } + + device, err := d.getEnhancedDevice() + if err != nil { + return fmt.Errorf("failed to get device: %w", err) + } + + spec := ostrace.DefaultLevelFilter() + conn, err := ostrace.New(device, -1, spec.MessageFilter, spec.StreamFlags) + if err != nil { + return fmt.Errorf("failed to connect to os_trace_relay: %w", err) + } + + type readResult struct { + entry LogEntry + err error + } + + ch := make(chan readResult, 1) + done := make(chan struct{}) + var closeOnce sync.Once + stopStreaming := func() { + closeOnce.Do(func() { + close(done) + _ = conn.Close() + }) + } + defer stopStreaming() + + go func() { + for { + raw, err := conn.ReadEntry() + result := readResult{err: err} + if err == nil { + result.entry = LogEntry{ + Timestamp: raw.Timestamp.Format("2006-01-02 15:04:05.000000-0700"), + Message: raw.Message, + Level: raw.LevelName, + PID: int(raw.PID), + Process: processNameFromPath(raw.Filename), + } + if raw.Label != nil { + result.entry.Subsystem = raw.Label.Subsystem + result.entry.Category = raw.Label.Category + } + } + select { + case <-done: + return + case ch <- result: + if err != nil { + return + } + } + } + }() + + for { + select { + case <-ctx.Done(): + return nil + case r := <-ch: + if r.err != nil { + if errors.Is(r.err, io.EOF) { + return nil + } + return fmt.Errorf("os_trace read error: %w", r.err) + } + if !onLog(r.entry) { + return nil + } + } + } +} + diff --git a/devices/remote.go b/devices/remote.go index ed20d7c9..93e374a5 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -1,6 +1,7 @@ package devices import ( + "context" "encoding/base64" "fmt" "io" @@ -515,3 +516,7 @@ func (r *RemoteDevice) GetCrashReport(id string) ([]byte, error) { } return []byte(result.Content), nil } + +func (r *RemoteDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { + return fmt.Errorf("device logs not yet supported for remote devices") +} diff --git a/devices/simulator.go b/devices/simulator.go index 1581dd3b..b3bf959b 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -1,7 +1,11 @@ package devices import ( + "context" + "encoding/json" + "errors" "fmt" + "io" "os" "os/exec" "os/signal" @@ -890,3 +894,83 @@ func (s SimulatorDevice) GetCrashReport(id string) ([]byte, error) { return os.ReadFile(filepath.Join(diagnosticReportsDir, id)) } + +// simctlLogEntry is the raw structure from xcrun simctl log stream --style json +type simctlLogEntry struct { + Timestamp string `json:"timestamp"` + EventMessage string `json:"eventMessage"` + MessageType string `json:"messageType"` + Subsystem string `json:"subsystem"` + Category string `json:"category"` + ProcessImagePath string `json:"processImagePath"` + ProcessID int `json:"processID"` +} + +func (s *SimulatorDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { + args := []string{"simctl", "spawn", s.UDID, "log", "stream", "--level", "info", "--style", "json"} + utils.Verbose("Running: xcrun %s", strings.Join(args, " ")) + + cmd := exec.CommandContext(ctx, "xcrun", args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start log stream: %w", err) + } + + decoder := json.NewDecoder(stdout) + + // read opening '[' of the JSON array + token, err := decoder.Token() + if err != nil { + _ = cmd.Process.Kill() + waitErr := cmd.Wait() + return fmt.Errorf("failed to read opening token: %w (process exit: %v)", err, waitErr) + } + if delim, ok := token.(json.Delim); !ok || delim != '[' { + _ = cmd.Process.Kill() + waitErr := cmd.Wait() + return fmt.Errorf("expected '[', got %v (process exit: %v)", token, waitErr) + } + + // decode entries one at a time until the stream ends + stoppedByCaller := false + for decoder.More() { + var raw simctlLogEntry + if err := decoder.Decode(&raw); err != nil { + if errors.Is(err, io.EOF) { + break + } + _ = cmd.Process.Kill() + waitErr := cmd.Wait() + return fmt.Errorf("failed to decode log entry: %w (process exit: %v)", err, waitErr) + } + + processName := processNameFromPath(raw.ProcessImagePath) + + if !onLog(LogEntry{ + Timestamp: raw.Timestamp, + Message: raw.EventMessage, + Level: raw.MessageType, + Subsystem: raw.Subsystem, + Category: raw.Category, + PID: raw.ProcessID, + Process: processName, + }) { + _ = cmd.Process.Kill() + stoppedByCaller = true + break + } + } + + waitErr := cmd.Wait() + if stoppedByCaller || ctx.Err() != nil { + return nil + } + if waitErr != nil { + return fmt.Errorf("log stream ended with error: %w", waitErr) + } + return nil +}