Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -281,6 +282,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 <device-id>

# Stop after 100 entries
mobilecli device logs --device <device-id> --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"}
```

## 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
Expand Down
57 changes: 57 additions & 0 deletions cli/logs.go
Original file line number Diff line number Diff line change
@@ -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)")
}
13 changes: 13 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ AGENT:
# Install on a real iOS device (requires provisioning profile)
mobilecli agent install --device <device-id> --provisioning-profile /path/to/profile.mobileprovision

DEVICE LOGS:
# Stream logs from a device (Ctrl+C to stop)
mobilecli device logs --device <device-id>

# Stop after N entries
mobilecli device logs --device <device-id> --limit 100

# Filter (key=value to include, key!=value to exclude; --filter is repeatable)
mobilecli device logs --device <device-id> --filter process=SpringBoard
mobilecli device logs --device <device-id> --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 <device-id> https://example.com
Expand Down
130 changes: 130 additions & 0 deletions commands/logs.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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")
}
100 changes: 100 additions & 0 deletions devices/android.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package devices

import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/xml"
Expand All @@ -13,6 +15,7 @@ import (
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"

Expand Down Expand Up @@ -1370,3 +1373,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
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.

}
Loading
Loading