From 01a66a3fa40b7883b4cbcbff648a5d7d81e160b1 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 15 Apr 2026 10:59:36 +0200 Subject: [PATCH 01/12] feat: add "device logs" command for streaming device logs Adds real-time log streaming from iOS simulators (via xcrun simctl log stream) and real iOS devices (via go-ios syslog_relay). Android and remote devices return "not yet supported". Supports --limit flag to stop after N entries. --- cli/logs.go | 33 ++++++++++++++++++++ commands/logs.go | 39 +++++++++++++++++++++++ devices/android.go | 4 +++ devices/common.go | 13 ++++++++ devices/ios.go | 58 ++++++++++++++++++++++++++++++++++ devices/remote.go | 4 +++ devices/simulator.go | 74 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 225 insertions(+) create mode 100644 cli/logs.go create mode 100644 commands/logs.go diff --git a/cli/logs.go b/cli/logs.go new file mode 100644 index 0000000..41b9548 --- /dev/null +++ b/cli/logs.go @@ -0,0 +1,33 @@ +package cli + +import ( + "fmt" + + "github.com/mobile-next/mobilecli/commands" + "github.com/spf13/cobra" +) + +var logsLimit int + +var deviceLogsCmd = &cobra.Command{ + Use: "logs", + Short: "Stream device logs", + Long: `Streams real-time logs from a device. Press Ctrl+C to stop.`, + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.LogsCommand(commands.LogsRequest{ + DeviceID: deviceId, + Limit: logsLimit, + }) + 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)") +} diff --git a/commands/logs.go b/commands/logs.go new file mode 100644 index 0000000..db10475 --- /dev/null +++ b/commands/logs.go @@ -0,0 +1,39 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/mobile-next/mobilecli/devices" +) + +type LogsRequest struct { + DeviceID string + Limit int +} + +func LogsCommand(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 + err = device.StreamLogs(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 + }) + 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 3ee3339..b1a5a17 100644 --- a/devices/android.go +++ b/devices/android.go @@ -1344,3 +1344,7 @@ func (d *AndroidDevice) GetCrashReport(id string) ([]byte, error) { } return []byte(content), nil } + +func (d *AndroidDevice) StreamLogs(onLog func(LogEntry) bool) error { + return fmt.Errorf("device logs not yet supported for Android devices") +} diff --git a/devices/common.go b/devices/common.go index 4abe2ff..2e21287 100644 --- a/devices/common.go +++ b/devices/common.go @@ -12,6 +12,18 @@ 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"` + EventType string `json:"eventType"` +} + type CrashReport struct { ProcessName string `json:"processName"` Timestamp string `json:"timestamp"` @@ -114,6 +126,7 @@ type ControllableDevice interface { SetOrientation(orientation string) error ListCrashReports() ([]CrashReport, error) GetCrashReport(id string) ([]byte, error) + StreamLogs(onLog func(LogEntry) bool) error } // GetAllControllableDevices aggregates all known devices with options diff --git a/devices/ios.go b/devices/ios.go index ecc6a36..30541e1 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -18,6 +18,7 @@ import ( goios "github.com/danielpaulus/go-ios/ios" "github.com/danielpaulus/go-ios/ios/crashreport" "github.com/danielpaulus/go-ios/ios/diagnostics" + "github.com/danielpaulus/go-ios/ios/syslog" "github.com/danielpaulus/go-ios/ios/installationproxy" "github.com/danielpaulus/go-ios/ios/instruments" "github.com/danielpaulus/go-ios/ios/testmanagerd" @@ -1662,3 +1663,60 @@ func (d *IOSDevice) GetCrashReport(id string) ([]byte, error) { return content, nil } + +func (d *IOSDevice) StreamLogs(onLog func(LogEntry) bool) error { + device, err := d.getEnhancedDevice() + if err != nil { + return fmt.Errorf("failed to get device: %w", err) + } + + conn, err := syslog.New(device) + if err != nil { + return fmt.Errorf("failed to connect to syslog: %w", err) + } + defer conn.Close() + + parse := syslog.Parser() + + for { + msg, err := conn.ReadLogMessage() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return fmt.Errorf("syslog read error: %w", err) + } + + msg = strings.TrimSuffix(msg, "\x00") + msg = strings.TrimSuffix(msg, "\x0A") + if msg == "" { + continue + } + + entry, err := parse(msg) + if err != nil { + // unparseable line — emit raw message + if !onLog(LogEntry{ + Message: msg, + }) { + return nil + } + continue + } + + if !onLog(LogEntry{ + Timestamp: entry.Timestamp, + Message: entry.Message, + Level: entry.Level, + Process: entry.Process, + PID: atoiOrZero(entry.PID), + }) { + return nil + } + } +} + +func atoiOrZero(s string) int { + n, _ := strconv.Atoi(s) + return n +} diff --git a/devices/remote.go b/devices/remote.go index 767dff2..1476337 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -500,3 +500,7 @@ func (r *RemoteDevice) GetCrashReport(id string) ([]byte, error) { } return []byte(result.Content), nil } + +func (r *RemoteDevice) StreamLogs(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 c691346..87b6389 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -1,6 +1,7 @@ package devices import ( + "encoding/json" "fmt" "os" "os/exec" @@ -1045,3 +1046,76 @@ 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"` + EventType string `json:"eventType"` +} + +func (s *SimulatorDevice) StreamLogs(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.Command("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() + return fmt.Errorf("failed to read opening token: %w", err) + } + if delim, ok := token.(json.Delim); !ok || delim != '[' { + _ = cmd.Process.Kill() + return fmt.Errorf("expected '[', got %v", token) + } + + // decode entries one at a time until the stream ends + for decoder.More() { + var raw simctlLogEntry + if err := decoder.Decode(&raw); err != nil { + // stream ended (process killed) — not an error + break + } + + // extract process name from full path + processName := raw.ProcessImagePath + if idx := strings.LastIndex(processName, "/"); idx != -1 { + processName = processName[idx+1:] + } + + if !onLog(LogEntry{ + Timestamp: raw.Timestamp, + Message: raw.EventMessage, + Level: raw.MessageType, + Subsystem: raw.Subsystem, + Category: raw.Category, + PID: raw.ProcessID, + Process: processName, + EventType: raw.EventType, + }) { + _ = cmd.Process.Kill() + break + } + } + + // we killed it ourselves, or stream ended naturally + _ = cmd.Wait() + return nil +} From 92de82f9e2db28a439649e14c3ffef277c490c32 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 15 Apr 2026 11:17:23 +0200 Subject: [PATCH 02/12] feat: add --process and --pid filters to device logs Filters are applied before --limit, so the user sees exactly N matching entries. --process does substring matching on the process name. --- cli/logs.go | 6 ++++++ commands/logs.go | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/cli/logs.go b/cli/logs.go index 41b9548..edbad69 100644 --- a/cli/logs.go +++ b/cli/logs.go @@ -8,6 +8,8 @@ import ( ) var logsLimit int +var logsProcess string +var logsPID int var deviceLogsCmd = &cobra.Command{ Use: "logs", @@ -17,6 +19,8 @@ var deviceLogsCmd = &cobra.Command{ response := commands.LogsCommand(commands.LogsRequest{ DeviceID: deviceId, Limit: logsLimit, + Process: logsProcess, + PID: logsPID, }) if response.Status == "error" { printJson(response) @@ -30,4 +34,6 @@ 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().StringVar(&logsProcess, "process", "", "Filter by process name (substring match)") + deviceLogsCmd.Flags().IntVar(&logsPID, "pid", 0, "Filter by process ID") } diff --git a/commands/logs.go b/commands/logs.go index db10475..d795e5a 100644 --- a/commands/logs.go +++ b/commands/logs.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/mobile-next/mobilecli/devices" ) @@ -11,6 +12,8 @@ import ( type LogsRequest struct { DeviceID string Limit int + Process string + PID int } func LogsCommand(req LogsRequest) *CommandResponse { @@ -22,6 +25,13 @@ func LogsCommand(req LogsRequest) *CommandResponse { encoder := json.NewEncoder(os.Stdout) count := 0 err = device.StreamLogs(func(entry devices.LogEntry) bool { + if req.Process != "" && !strings.Contains(entry.Process, req.Process) { + return true + } + if req.PID > 0 && entry.PID != req.PID { + return true + } + if err := encoder.Encode(entry); err != nil { return false } From c5fdce5fa74f12e50caef1d4f9dd857204bc0efa Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 15 Apr 2026 11:19:19 +0200 Subject: [PATCH 03/12] fix: use -1 as default for --pid flag since pid 0 is valid --- cli/logs.go | 2 +- commands/logs.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/logs.go b/cli/logs.go index edbad69..0b4e6d4 100644 --- a/cli/logs.go +++ b/cli/logs.go @@ -35,5 +35,5 @@ func init() { 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().StringVar(&logsProcess, "process", "", "Filter by process name (substring match)") - deviceLogsCmd.Flags().IntVar(&logsPID, "pid", 0, "Filter by process ID") + deviceLogsCmd.Flags().IntVar(&logsPID, "pid", -1, "Filter by process ID") } diff --git a/commands/logs.go b/commands/logs.go index d795e5a..1dd1e06 100644 --- a/commands/logs.go +++ b/commands/logs.go @@ -28,7 +28,7 @@ func LogsCommand(req LogsRequest) *CommandResponse { if req.Process != "" && !strings.Contains(entry.Process, req.Process) { return true } - if req.PID > 0 && entry.PID != req.PID { + if req.PID >= 0 && entry.PID != req.PID { return true } From 10ddb967a78a36400fe2b286a45be391d509904b Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 15 Apr 2026 11:58:18 +0200 Subject: [PATCH 04/12] feat: add Android logcat support to device logs Streams logs via adb logcat with threadtime,year format. Parses using existing logcatLineRegex. Resolves PID to package name via one-time adb shell ps call for --process filtering. Adds tag field to LogEntry (populated for Android, omitted for iOS). --- devices/android.go | 86 +++++++++++++++++++++++++++++++++++++++++++++- devices/common.go | 3 +- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/devices/android.go b/devices/android.go index b1a5a17..7e14a1a 100644 --- a/devices/android.go +++ b/devices/android.go @@ -1,6 +1,7 @@ package devices import ( + "bufio" "context" "encoding/base64" "encoding/xml" @@ -1345,6 +1346,89 @@ func (d *AndroidDevice) GetCrashReport(id string) ([]byte, error) { return []byte(content), nil } +// getPidToProcessMap runs "adb shell ps" and returns a map of PID→process name +func (d *AndroidDevice) getPidToProcessMap() map[int]string { + output, err := d.runAdbCommand("shell", "ps", "-e", "-o", "PID,NAME") + if err != nil { + return nil + } + + m := make(map[int]string) + for _, line := range strings.Split(string(output), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + m[pid] = fields[1] + } + return m +} + +var logcatLevelMap = map[string]string{ + "V": "Verbose", + "D": "Debug", + "I": "Info", + "W": "Warning", + "E": "Error", + "F": "Fatal", + "A": "Assert", +} + func (d *AndroidDevice) StreamLogs(onLog func(LogEntry) bool) error { - return fmt.Errorf("device logs not yet supported for Android devices") + // build PID→process name map for --process filtering + pidMap := d.getPidToProcessMap() + + args := []string{"logcat", "-v", "threadtime,year", "-T", "1"} + cmdArgs := append([]string{"-s", d.getAdbIdentifier()}, args...) + cmd := exec.Command(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) + 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, + } + + // resolve process name from ps map (for --process filtering) + if pidMap != nil { + entry.Process = pidMap[pid] + } + + if !onLog(entry) { + _ = cmd.Process.Kill() + break + } + } + + _ = cmd.Wait() + return nil } diff --git a/devices/common.go b/devices/common.go index 2e21287..a308dda 100644 --- a/devices/common.go +++ b/devices/common.go @@ -20,7 +20,8 @@ type LogEntry struct { Subsystem string `json:"subsystem,omitempty"` Category string `json:"category,omitempty"` PID int `json:"pid"` - Process string `json:"process"` + Process string `json:"process,omitempty"` + Tag string `json:"tag,omitempty"` EventType string `json:"eventType"` } From 96c026c37b8ba660ff0783ed48cef8d1068ac03b Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 15 Apr 2026 11:58:57 +0200 Subject: [PATCH 05/12] fix: use exact match for --process filter instead of substring --- commands/logs.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/commands/logs.go b/commands/logs.go index 1dd1e06..5ca7766 100644 --- a/commands/logs.go +++ b/commands/logs.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "strings" "github.com/mobile-next/mobilecli/devices" ) @@ -25,7 +24,7 @@ func LogsCommand(req LogsRequest) *CommandResponse { encoder := json.NewEncoder(os.Stdout) count := 0 err = device.StreamLogs(func(entry devices.LogEntry) bool { - if req.Process != "" && !strings.Contains(entry.Process, req.Process) { + if req.Process != "" && entry.Process != req.Process { return true } if req.PID >= 0 && entry.PID != req.PID { From 750644960c86a694c10634aea600cbc3b3eef4f8 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 15 Apr 2026 13:51:33 +0200 Subject: [PATCH 06/12] refactor: clean code improvements for device logs - Fix --process flag description to say "exact match" - Split callback into matchesFilter and emit helpers - Extract processNameFromPath and atoiOrZero to common.go --- cli/logs.go | 2 +- commands/logs.go | 25 ++++++++++++++++++------- devices/common.go | 16 ++++++++++++++++ devices/ios.go | 4 ---- devices/simulator.go | 6 +----- 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/cli/logs.go b/cli/logs.go index 0b4e6d4..2e4b3a1 100644 --- a/cli/logs.go +++ b/cli/logs.go @@ -34,6 +34,6 @@ 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().StringVar(&logsProcess, "process", "", "Filter by process name (substring match)") + deviceLogsCmd.Flags().StringVar(&logsProcess, "process", "", "Filter by process name (exact match)") deviceLogsCmd.Flags().IntVar(&logsPID, "pid", -1, "Filter by process ID") } diff --git a/commands/logs.go b/commands/logs.go index 5ca7766..78272fe 100644 --- a/commands/logs.go +++ b/commands/logs.go @@ -23,14 +23,8 @@ func LogsCommand(req LogsRequest) *CommandResponse { encoder := json.NewEncoder(os.Stdout) count := 0 - err = device.StreamLogs(func(entry devices.LogEntry) bool { - if req.Process != "" && entry.Process != req.Process { - return true - } - if req.PID >= 0 && entry.PID != req.PID { - return true - } + emit := func(entry devices.LogEntry) bool { if err := encoder.Encode(entry); err != nil { return false } @@ -39,6 +33,23 @@ func LogsCommand(req LogsRequest) *CommandResponse { return false } return true + } + + matchesFilter := func(entry devices.LogEntry) bool { + if req.Process != "" && entry.Process != req.Process { + return false + } + if req.PID >= 0 && entry.PID != req.PID { + return false + } + return true + } + + err = device.StreamLogs(func(entry devices.LogEntry) bool { + if !matchesFilter(entry) { + return true + } + return emit(entry) }) if err != nil { return NewErrorResponse(fmt.Errorf("error streaming logs: %w", err)) diff --git a/devices/common.go b/devices/common.go index a308dda..92473fd 100644 --- a/devices/common.go +++ b/devices/common.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "regexp" + "strconv" + "strings" "time" "github.com/mobile-next/mobilecli/devices/wda" @@ -25,6 +27,20 @@ type LogEntry struct { EventType string `json:"eventType"` } +// 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 +} + +// atoiOrZero converts a string to int, returning 0 on failure +func atoiOrZero(s string) int { + n, _ := strconv.Atoi(s) + return n +} + type CrashReport struct { ProcessName string `json:"processName"` Timestamp string `json:"timestamp"` diff --git a/devices/ios.go b/devices/ios.go index 30541e1..05f791d 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -1716,7 +1716,3 @@ func (d *IOSDevice) StreamLogs(onLog func(LogEntry) bool) error { } } -func atoiOrZero(s string) int { - n, _ := strconv.Atoi(s) - return n -} diff --git a/devices/simulator.go b/devices/simulator.go index 87b6389..d51c8ab 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -1094,11 +1094,7 @@ func (s *SimulatorDevice) StreamLogs(onLog func(LogEntry) bool) error { break } - // extract process name from full path - processName := raw.ProcessImagePath - if idx := strings.LastIndex(processName, "/"); idx != -1 { - processName = processName[idx+1:] - } + processName := processNameFromPath(raw.ProcessImagePath) if !onLog(LogEntry{ Timestamp: raw.Timestamp, From cec05802b10cdbb81a14dd31935445dca601e5fa Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 15 Apr 2026 14:22:53 +0200 Subject: [PATCH 07/12] feat: replace --process/--pid/--tag with repeatable --filter flag Supports key=value (include) and key!=value (exclude) syntax. Multiple filters are ANDed. Valid keys: pid, process, tag, level, subsystem, category, message. Invalid keys return a clear error. --- cli/logs.go | 25 +++++++++---- commands/logs.go | 96 +++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/cli/logs.go b/cli/logs.go index 2e4b3a1..493a5c9 100644 --- a/cli/logs.go +++ b/cli/logs.go @@ -8,19 +8,31 @@ import ( ) var logsLimit int -var logsProcess string -var logsPID 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.`, + 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 { + filters, err := commands.ParseLogFilters(logsFilters) + if err != nil { + return err + } response := commands.LogsCommand(commands.LogsRequest{ DeviceID: deviceId, Limit: logsLimit, - Process: logsProcess, - PID: logsPID, + Filters: filters, }) if response.Status == "error" { printJson(response) @@ -34,6 +46,5 @@ 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().StringVar(&logsProcess, "process", "", "Filter by process name (exact match)") - deviceLogsCmd.Flags().IntVar(&logsPID, "pid", -1, "Filter by process ID") + deviceLogsCmd.Flags().StringArrayVar(&logsFilters, "filter", nil, "Filter logs (key=value or key!=value, repeatable)") } diff --git a/commands/logs.go b/commands/logs.go index 78272fe..2b299c1 100644 --- a/commands/logs.go +++ b/commands/logs.go @@ -4,15 +4,95 @@ import ( "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 - Process string - PID 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(req LogsRequest) *CommandResponse { @@ -35,18 +115,8 @@ func LogsCommand(req LogsRequest) *CommandResponse { return true } - matchesFilter := func(entry devices.LogEntry) bool { - if req.Process != "" && entry.Process != req.Process { - return false - } - if req.PID >= 0 && entry.PID != req.PID { - return false - } - return true - } - err = device.StreamLogs(func(entry devices.LogEntry) bool { - if !matchesFilter(entry) { + if !matchesFilters(entry, req.Filters) { return true } return emit(entry) From 561a2100884d7c974e5e4c88704405a26ba92905 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 15 Apr 2026 14:24:18 +0200 Subject: [PATCH 08/12] docs: add device logs section to README --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index c440ff2..c3e5bf7 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ A universal command-line tool for managing iOS and Android devices, simulators, - **Device Control**: Reboot devices, tap screen coordinates, press hardware buttons - **App Management**: Launch, terminate, install, uninstall, list, and get foreground apps - **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 ### 🎯 Platform Support @@ -247,6 +248,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","eventType":"logEvent"} +``` + ## HTTP API 🔌 ***mobilecli*** provides an http interface for all the functionality that is available through command line. As a matter of fact, it is preferable to From b0b9d4034f0b8f4150601106114b679b851a316d Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sat, 25 Apr 2026 10:40:35 +0200 Subject: [PATCH 09/12] feat: switch iOS real-device logs to os_trace_relay Replaces legacy syslog_relay (ASL) with structured os_trace_relay via go-ios v1.0.211's new ostrace package. Real iOS devices now expose the same subsystem/category/structured-level fields the simulator already provided, so cross-platform --filter keys work uniformly on hardware. Removes EventType from LogEntry: it was simctl-only metadata that could not be populated on iOS-real (the ostrace binding does not surface record types and we filter to log messages only) or Android (logcat has no equivalent), and no consumer read it. Drops the now-unused atoiOrZero helper. Documents device logs --filter in the root help text. --- README.md | 2 +- cli/root.go | 13 +++++++++++++ devices/common.go | 8 -------- devices/ios.go | 46 ++++++++++++++++---------------------------- devices/simulator.go | 2 -- go.mod | 5 ++++- go.sum | 10 ++++++++-- 7 files changed, 43 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index c3e5bf7..066e0cf 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ Supported filter keys: `pid`, `process`, `tag`, `level`, `subsystem`, `category` 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","eventType":"logEvent"} +{"timestamp":"2026-04-15 12:17:14.224451+0300","message":"Start proc...","level":"Default","subsystem":"com.apple.UIKit","category":"EventDispatch","pid":54052,"process":"SpringBoard"} ``` ## HTTP API 🔌 diff --git a/cli/root.go b/cli/root.go index 5a9f712..4b3046e 100644 --- a/cli/root.go +++ b/cli/root.go @@ -98,6 +98,19 @@ CRASH REPORTS: # Get a specific crash report mobilecli crashes get --device +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/devices/common.go b/devices/common.go index 92473fd..6a71d40 100644 --- a/devices/common.go +++ b/devices/common.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "regexp" - "strconv" "strings" "time" @@ -24,7 +23,6 @@ type LogEntry struct { PID int `json:"pid"` Process string `json:"process,omitempty"` Tag string `json:"tag,omitempty"` - EventType string `json:"eventType"` } // processNameFromPath extracts the binary name from a full image path @@ -35,12 +33,6 @@ func processNameFromPath(path string) string { return path } -// atoiOrZero converts a string to int, returning 0 on failure -func atoiOrZero(s string) int { - n, _ := strconv.Atoi(s) - return n -} - type CrashReport struct { ProcessName string `json:"processName"` Timestamp string `json:"timestamp"` diff --git a/devices/ios.go b/devices/ios.go index 05f791d..cf14df1 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -18,8 +18,8 @@ import ( goios "github.com/danielpaulus/go-ios/ios" "github.com/danielpaulus/go-ios/ios/crashreport" "github.com/danielpaulus/go-ios/ios/diagnostics" - "github.com/danielpaulus/go-ios/ios/syslog" "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" @@ -1670,47 +1670,35 @@ func (d *IOSDevice) StreamLogs(onLog func(LogEntry) bool) error { return fmt.Errorf("failed to get device: %w", err) } - conn, err := syslog.New(device) + spec := ostrace.DefaultLevelFilter() + conn, err := ostrace.New(device, -1, spec.MessageFilter, spec.StreamFlags) if err != nil { - return fmt.Errorf("failed to connect to syslog: %w", err) + return fmt.Errorf("failed to connect to os_trace_relay: %w", err) } - defer conn.Close() - - parse := syslog.Parser() + defer func() { _ = conn.Close() }() for { - msg, err := conn.ReadLogMessage() + entry, err := conn.ReadEntry() if err != nil { if errors.Is(err, io.EOF) { return nil } - return fmt.Errorf("syslog read error: %w", err) + return fmt.Errorf("os_trace read error: %w", err) } - msg = strings.TrimSuffix(msg, "\x00") - msg = strings.TrimSuffix(msg, "\x0A") - if msg == "" { - continue + out := LogEntry{ + Timestamp: entry.Timestamp.Format("2006-01-02 15:04:05.000000-0700"), + Message: entry.Message, + Level: entry.LevelName, + PID: int(entry.PID), + Process: processNameFromPath(entry.Filename), } - - entry, err := parse(msg) - if err != nil { - // unparseable line — emit raw message - if !onLog(LogEntry{ - Message: msg, - }) { - return nil - } - continue + if entry.Label != nil { + out.Subsystem = entry.Label.Subsystem + out.Category = entry.Label.Category } - if !onLog(LogEntry{ - Timestamp: entry.Timestamp, - Message: entry.Message, - Level: entry.Level, - Process: entry.Process, - PID: atoiOrZero(entry.PID), - }) { + if !onLog(out) { return nil } } diff --git a/devices/simulator.go b/devices/simulator.go index d51c8ab..ad4c38d 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -1056,7 +1056,6 @@ type simctlLogEntry struct { Category string `json:"category"` ProcessImagePath string `json:"processImagePath"` ProcessID int `json:"processID"` - EventType string `json:"eventType"` } func (s *SimulatorDevice) StreamLogs(onLog func(LogEntry) bool) error { @@ -1104,7 +1103,6 @@ func (s *SimulatorDevice) StreamLogs(onLog func(LogEntry) bool) error { Category: raw.Category, PID: raw.ProcessID, Process: processName, - EventType: raw.EventType, }) { _ = cmd.Process.Kill() break diff --git a/go.mod b/go.mod index 2090f2e..9cdf4bb 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/mobile-next/mobilecli go 1.25.7 require ( - github.com/danielpaulus/go-ios v1.0.182 + github.com/danielpaulus/go-ios v1.0.211 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -36,11 +36,14 @@ require ( github.com/miekg/dns v1.1.57 // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/pierrec/lz4 v2.6.1+incompatible // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/quic-go v0.40.1-0.20231203135336-87ef8ec48d55 // indirect github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect + github.com/vishvananda/netlink v1.3.1 // indirect + github.com/vishvananda/netns v0.0.5 // indirect go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/crypto v0.45.0 // indirect diff --git a/go.sum b/go.sum index e5d19ce..ff3a28c 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= -github.com/danielpaulus/go-ios v1.0.182 h1:SnJKO3rzQClRviPwMqFf2uWX6AFxBDbUCnl88ZsfJlw= -github.com/danielpaulus/go-ios v1.0.182/go.mod h1:ZkUcaC59yNba47j/+ULKsCi3dYPFwY9r39PxdmVmLHE= +github.com/danielpaulus/go-ios v1.0.211 h1:REv11Hc+kt3LEAEwYkps0r7KUew0kYWs1rgw2UJeEug= +github.com/danielpaulus/go-ios v1.0.211/go.mod h1:f5q5S4XJT53AA8cdgp3rLA41YaIpyaDg+w8aURzLNhM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -90,6 +90,10 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= @@ -126,6 +130,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= From b53ca635c9eb0cedcf703ce66bc36c9846a8508f Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sat, 25 Apr 2026 10:49:12 +0200 Subject: [PATCH 10/12] fix: harden device log streaming on iOS and simulator iOS real device: StreamLogs now starts the iOS 17+ tunnel before opening the os_trace_relay connection, matching Reboot/LaunchApp/TerminateApp. Without this, ostrace.New fails on devices that need a tunnel. Simulator: surface real read failures from the simctl JSON stream and reap the child process on every error path. Previously, decoder.Token and decoder.Decode errors were swallowed as "stream ended" and cmd.Wait was ignored, so a non-zero simctl exit (bad UDID, device shutdown mid-stream) returned success and left a zombie. EOF from Decode is still treated as a normal end-of-stream; caller-initiated stops are tracked separately so the kill we issue ourselves is not reported as an error. --- devices/ios.go | 6 ++++++ devices/simulator.go | 29 +++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/devices/ios.go b/devices/ios.go index ec76c11..9570cef 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -1630,6 +1630,12 @@ func (d *IOSDevice) GetCrashReport(id string) ([]byte, error) { } func (d *IOSDevice) StreamLogs(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) diff --git a/devices/simulator.go b/devices/simulator.go index 1e5f629..13774fb 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -2,7 +2,9 @@ package devices import ( "encoding/json" + "errors" "fmt" + "io" "os" "os/exec" "os/signal" @@ -921,19 +923,26 @@ func (s *SimulatorDevice) StreamLogs(onLog func(LogEntry) bool) error { token, err := decoder.Token() if err != nil { _ = cmd.Process.Kill() - return fmt.Errorf("failed to read opening token: %w", err) + 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() - return fmt.Errorf("expected '[', got %v", token) + 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 { - // stream ended (process killed) — not an error - break + 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) @@ -948,11 +957,19 @@ func (s *SimulatorDevice) StreamLogs(onLog func(LogEntry) bool) error { Process: processName, }) { _ = cmd.Process.Kill() + stoppedByCaller = true break } } - // we killed it ourselves, or stream ended naturally - _ = cmd.Wait() + waitErr := cmd.Wait() + if stoppedByCaller { + // process was killed because the caller signaled stop; + // the wait error reflects our own kill, not a real failure + return nil + } + if waitErr != nil { + return fmt.Errorf("log stream ended with error: %w", waitErr) + } return nil } From 5c044679a0f33e984e4cef5b3c7173fca0530fd5 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sat, 25 Apr 2026 19:06:25 +0200 Subject: [PATCH 11/12] fix: add context propagation to StreamLogs for cancellation on dead connections - thread context.Context through ControllableDevice.StreamLogs interface - use exec.CommandContext on Android and Simulator so the subprocess is killed automatically when the context is cancelled - replace blocking iOS ReadEntry loop with goroutine+channel+select to support ctx.Done without hanging on dead connections - wire signal.NotifyContext in CLI so Ctrl+C cancels the context cleanly - fix Android scanner: larger 256KB buffer and check scanner.Err() after loop --- cli/logs.go | 9 +++++- commands/logs.go | 5 +-- devices/android.go | 13 ++++++-- devices/common.go | 3 +- devices/ios.go | 76 ++++++++++++++++++++++++++++++++------------ devices/remote.go | 3 +- devices/simulator.go | 9 +++--- 7 files changed, 85 insertions(+), 33 deletions(-) diff --git a/cli/logs.go b/cli/logs.go index 493a5c9..386d008 100644 --- a/cli/logs.go +++ b/cli/logs.go @@ -1,7 +1,11 @@ package cli import ( + "context" "fmt" + "os" + "os/signal" + "syscall" "github.com/mobile-next/mobilecli/commands" "github.com/spf13/cobra" @@ -25,11 +29,14 @@ Examples: 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(commands.LogsRequest{ + response := commands.LogsCommand(ctx, commands.LogsRequest{ DeviceID: deviceId, Limit: logsLimit, Filters: filters, diff --git a/commands/logs.go b/commands/logs.go index 2b299c1..2938be1 100644 --- a/commands/logs.go +++ b/commands/logs.go @@ -1,6 +1,7 @@ package commands import ( + "context" "encoding/json" "fmt" "os" @@ -95,7 +96,7 @@ func matchesFilters(entry devices.LogEntry, filters []LogFilter) bool { return true } -func LogsCommand(req LogsRequest) *CommandResponse { +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)) @@ -115,7 +116,7 @@ func LogsCommand(req LogsRequest) *CommandResponse { return true } - err = device.StreamLogs(func(entry devices.LogEntry) bool { + err = device.StreamLogs(ctx, func(entry devices.LogEntry) bool { if !matchesFilters(entry, req.Filters) { return true } diff --git a/devices/android.go b/devices/android.go index a5aac16..9ac02a7 100644 --- a/devices/android.go +++ b/devices/android.go @@ -1404,13 +1404,13 @@ var logcatLevelMap = map[string]string{ "A": "Assert", } -func (d *AndroidDevice) StreamLogs(onLog func(LogEntry) bool) error { +func (d *AndroidDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { // build PID→process name map for --process filtering pidMap := d.getPidToProcessMap() args := []string{"logcat", "-v", "threadtime,year", "-T", "1"} cmdArgs := append([]string{"-s", d.getAdbIdentifier()}, args...) - cmd := exec.Command(getAdbPath(), cmdArgs...) + cmd := exec.CommandContext(ctx, getAdbPath(), cmdArgs...) stdout, err := cmd.StdoutPipe() if err != nil { @@ -1422,6 +1422,8 @@ func (d *AndroidDevice) StreamLogs(onLog func(LogEntry) bool) error { } scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + stoppedByCaller := false for scanner.Scan() { line := scanner.Text() @@ -1451,10 +1453,17 @@ func (d *AndroidDevice) StreamLogs(onLog func(LogEntry) bool) error { 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 fb946dc..ad5d85b 100644 --- a/devices/common.go +++ b/devices/common.go @@ -1,6 +1,7 @@ package devices import ( + "context" "encoding/json" "fmt" "os" @@ -149,7 +150,7 @@ type ControllableDevice interface { SetOrientation(orientation string) error ListCrashReports() ([]CrashReport, error) GetCrashReport(id string) ([]byte, error) - StreamLogs(onLog func(LogEntry) bool) error + StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error } // GetAllControllableDevices aggregates all known devices with options diff --git a/devices/ios.go b/devices/ios.go index 9570cef..b9c0ff1 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -1629,7 +1629,7 @@ func (d *IOSDevice) GetCrashReport(id string) ([]byte, error) { return content, nil } -func (d *IOSDevice) StreamLogs(onLog func(LogEntry) bool) error { +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 { @@ -1646,31 +1646,65 @@ func (d *IOSDevice) StreamLogs(onLog func(LogEntry) bool) error { if err != nil { return fmt.Errorf("failed to connect to os_trace_relay: %w", err) } - defer func() { _ = conn.Close() }() - for { - entry, err := conn.ReadEntry() - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return fmt.Errorf("os_trace read error: %w", err) - } + type readResult struct { + entry LogEntry + err error + } - out := LogEntry{ - Timestamp: entry.Timestamp.Format("2006-01-02 15:04:05.000000-0700"), - Message: entry.Message, - Level: entry.LevelName, - PID: int(entry.PID), - Process: processNameFromPath(entry.Filename), - } - if entry.Label != nil { - out.Subsystem = entry.Label.Subsystem - out.Category = entry.Label.Category + 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 + } + } } + }() - if !onLog(out) { + 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 71e8549..349508a 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -1,6 +1,7 @@ package devices import ( + "context" "encoding/base64" "fmt" "io" @@ -501,6 +502,6 @@ func (r *RemoteDevice) GetCrashReport(id string) ([]byte, error) { return []byte(result.Content), nil } -func (r *RemoteDevice) StreamLogs(onLog func(LogEntry) bool) error { +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 13774fb..f77a64b 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -1,6 +1,7 @@ package devices import ( + "context" "encoding/json" "errors" "fmt" @@ -903,11 +904,11 @@ type simctlLogEntry struct { ProcessID int `json:"processID"` } -func (s *SimulatorDevice) StreamLogs(onLog func(LogEntry) bool) error { +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.Command("xcrun", args...) + cmd := exec.CommandContext(ctx, "xcrun", args...) stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("failed to create stdout pipe: %w", err) @@ -963,9 +964,7 @@ func (s *SimulatorDevice) StreamLogs(onLog func(LogEntry) bool) error { } waitErr := cmd.Wait() - if stoppedByCaller { - // process was killed because the caller signaled stop; - // the wait error reflects our own kill, not a real failure + if stoppedByCaller || ctx.Err() != nil { return nil } if waitErr != nil { From 183d83ab4d5cdae1a154b6cbea3124cb72fd17d7 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sat, 25 Apr 2026 19:36:19 +0200 Subject: [PATCH 12/12] fix: replace stale ps snapshot with lazy per-pid cmdline cache on Android resolve process names on demand via /proc//cmdline instead of a one-shot ps snapshot at startup, so long-running streams stay accurate as processes come and go --- devices/android.go | 51 ++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/devices/android.go b/devices/android.go index 9ac02a7..e630060 100644 --- a/devices/android.go +++ b/devices/android.go @@ -2,6 +2,7 @@ package devices import ( "bufio" + "bytes" "context" "encoding/base64" "encoding/xml" @@ -14,6 +15,7 @@ import ( "runtime" "strconv" "strings" + "sync" "syscall" "time" @@ -1372,26 +1374,32 @@ func (d *AndroidDevice) GetCrashReport(id string) ([]byte, error) { return []byte(content), nil } -// getPidToProcessMap runs "adb shell ps" and returns a map of PID→process name -func (d *AndroidDevice) getPidToProcessMap() map[int]string { - output, err := d.runAdbCommand("shell", "ps", "-e", "-o", "PID,NAME") - if err != nil { - return 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 } - m := make(map[int]string) - for _, line := range strings.Split(string(output), "\n") { - fields := strings.Fields(line) - if len(fields) != 2 { - continue - } - pid, err := strconv.Atoi(fields[0]) - if err != nil { - continue - } - m[pid] = fields[1] + 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 "" } - return m + // 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{ @@ -1405,8 +1413,7 @@ var logcatLevelMap = map[string]string{ } func (d *AndroidDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { - // build PID→process name map for --process filtering - pidMap := d.getPidToProcessMap() + pidCache := &androidPidCache{names: make(map[int]string)} args := []string{"logcat", "-v", "threadtime,year", "-T", "1"} cmdArgs := append([]string{"-s", d.getAdbIdentifier()}, args...) @@ -1444,11 +1451,7 @@ func (d *AndroidDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) boo Level: level, Tag: parsed.Tag, Message: parsed.Message, - } - - // resolve process name from ps map (for --process filtering) - if pidMap != nil { - entry.Process = pidMap[pid] + Process: pidCache.resolveProcessNameByPid(d, pid), } if !onLog(entry) {