Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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")
}
49 changes: 49 additions & 0 deletions commands/logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package commands

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

"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 != "" && !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
}
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")
}
4 changes: 4 additions & 0 deletions devices/android.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
13 changes: 13 additions & 0 deletions devices/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
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