Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions cli/logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package cli

import (
"fmt"

"github.com/mobile-next/mobilecli/commands"
"github.com/spf13/cobra"
)

var logsLimit int
var logsProcess string
var logsPID 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,
Process: logsProcess,
PID: logsPID,
})
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().StringVar(&logsProcess, "process", "", "Filter by process name (substring match)")
deviceLogsCmd.Flags().IntVar(&logsPID, "pid", -1, "Filter by process ID")
}
48 changes: 48 additions & 0 deletions commands/logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package commands

import (
"encoding/json"
"fmt"
"os"

"github.com/mobile-next/mobilecli/devices"
)

type LogsRequest struct {
DeviceID string
Limit int
Process string
PID 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 req.Process != "" && 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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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")
}
88 changes: 88 additions & 0 deletions devices/android.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package devices

import (
"bufio"
"context"
"encoding/base64"
"encoding/xml"
Expand Down Expand Up @@ -1344,3 +1345,90 @@ 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 {
// 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]
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if !onLog(entry) {
_ = cmd.Process.Kill()
break
}
}

_ = cmd.Wait()
return nil
Comment on lines +1431 to +1471
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find the file and examine the target lines
git ls-files devices/android.go

Repository: mobile-next/mobilecli

Length of output: 84


🏁 Script executed:

# Get the file size to ensure we can read it
wc -l devices/android.go

Repository: mobile-next/mobilecli

Length of output: 89


🏁 Script executed:

# Read the target lines and context around them
sed -n '1400,1470p' devices/android.go

Repository: mobile-next/mobilecli

Length of output: 1360


Propagate scanner and subprocess errors instead of silently returning success.

The StreamLogs function declares error as its return type but always returns nil, discarding both scanner.Err() and cmd.Wait() results. Device disconnects, adb failures, broken pipes, and scanner read errors are invisible to callers. The distinction between intentional stop (when onLog returns false) and actual errors must be preserved.

Suggested fix
 	scanner := bufio.NewScanner(stdout)
+	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+	stoppedByCaller := false
 	for scanner.Scan() {
 		line := scanner.Text()
@@
 		if !onLog(entry) {
+			stoppedByCaller = true
 			_ = cmd.Process.Kill()
 			break
 		}
 	}
 
-	_ = cmd.Wait()
-	return nil
+	if err := scanner.Err(); err != nil && !stoppedByCaller {
+		_ = cmd.Process.Kill()
+		_ = cmd.Wait()
+		return fmt.Errorf("failed reading logcat output: %w", err)
+	}
+
+	if err := cmd.Wait(); err != nil && !stoppedByCaller {
+		return fmt.Errorf("logcat exited unexpectedly: %w", err)
+	}
+	return nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*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,
}
// resolve process name from ps map (for --process filtering)
if pidMap != nil {
entry.Process = pidMap[pid]
}
if !onLog(entry) {
stoppedByCaller = true
_ = cmd.Process.Kill()
break
}
}
if err := scanner.Err(); err != nil && !stoppedByCaller {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return fmt.Errorf("failed reading logcat output: %w", err)
}
if err := cmd.Wait(); err != nil && !stoppedByCaller {
return fmt.Errorf("logcat exited unexpectedly: %w", err)
}
return nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@devices/android.go` around lines 1424 - 1459, StreamLogs currently swallows
scanner and subprocess errors; change it to propagate them: after the scanner
loop check scanner.Err() and return it if non-nil (unless we intentionally
stopped because onLog returned false), and after breaking/killing the child call
cmd.Wait() and return its error instead of always returning nil; use the onLog
return value to distinguish an intentional stop (treat as nil) from actual
errors, and ensure any error from cmd.Wait() is returned when the stop was not
intentional. Reference: StreamLogs function, scanner variable, onLog callback,
and cmd.Wait()/cmd.Process.Kill() calls.

}
14 changes: 14 additions & 0 deletions devices/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ 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"`
EventType string `json:"eventType"`
}

type CrashReport struct {
ProcessName string `json:"processName"`
Timestamp string `json:"timestamp"`
Expand Down Expand Up @@ -114,6 +127,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
Expand Down
58 changes: 58 additions & 0 deletions devices/ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

func atoiOrZero(s string) int {
n, _ := strconv.Atoi(s)
return n
}
4 changes: 4 additions & 0 deletions devices/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
74 changes: 74 additions & 0 deletions devices/simulator.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package devices

import (
"encoding/json"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading