diff --git a/cli/flags.go b/cli/flags.go index 0248b7f..94dea44 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -18,6 +18,11 @@ var ( platform string deviceType string + // for screenrecord command + screenrecordBitRate string + screenrecordTimeLimit int + screenrecordOutput string + // for fleet allocate command fleetType string fleetVersions []string diff --git a/cli/screenrecord.go b/cli/screenrecord.go new file mode 100644 index 0000000..f8a4bd0 --- /dev/null +++ b/cli/screenrecord.go @@ -0,0 +1,39 @@ +package cli + +import ( + "fmt" + + "github.com/mobile-next/mobilecli/commands" + "github.com/spf13/cobra" +) + +var screenrecordCmd = &cobra.Command{ + Use: "screenrecord", + Short: "Record video from a connected device", + Long: `Records video from a specified device and saves it as an MP4 file. Supports iOS (real/simulator) and Android (real/emulator).`, + RunE: func(cmd *cobra.Command, args []string) error { + req := commands.ScreenRecordRequest{ + DeviceID: deviceId, + BitRate: screenrecordBitRate, + TimeLimit: screenrecordTimeLimit, + OutputPath: screenrecordOutput, + } + + response := commands.ScreenRecordCommand(req) + + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(screenrecordCmd) + + screenrecordCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to record from") + screenrecordCmd.Flags().StringVar(&screenrecordBitRate, "bit-rate", "8M", "Video bit rate (e.g., 4M, 500K, 8000000)") + screenrecordCmd.Flags().IntVar(&screenrecordTimeLimit, "time-limit", 300, "Maximum recording time in seconds (max 300)") + screenrecordCmd.Flags().StringVarP(&screenrecordOutput, "output", "o", "", "Output file path (default: screenrecord--.mp4)") +} diff --git a/commands/screenrecord.go b/commands/screenrecord.go new file mode 100644 index 0000000..3e27eae --- /dev/null +++ b/commands/screenrecord.go @@ -0,0 +1,123 @@ +package commands + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/mobile-next/mobilecli/devices" +) + +// ScreenRecordRequest represents the parameters for recording video +type ScreenRecordRequest struct { + DeviceID string `json:"deviceId"` + BitRate string `json:"bitRate,omitempty"` + TimeLimit int `json:"timeLimit,omitempty"` + OutputPath string `json:"outputPath,omitempty"` +} + +// ScreenRecordResponse represents the response for a screenrecord command +type ScreenRecordResponse struct { + FilePath string `json:"filePath"` +} + +// parseBitRate parses a bit-rate string like "4M", "500K", or "8000000" into an integer +func parseBitRate(s string) (int, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty bit-rate string") + } + + upper := strings.ToUpper(s) + + if strings.HasSuffix(upper, "M") { + num, err := strconv.ParseFloat(upper[:len(upper)-1], 64) + if err != nil { + return 0, fmt.Errorf("invalid bit-rate: %w", err) + } + return int(num * 1_000_000), nil + } + + if strings.HasSuffix(upper, "K") { + num, err := strconv.ParseFloat(upper[:len(upper)-1], 64) + if err != nil { + return 0, fmt.Errorf("invalid bit-rate: %w", err) + } + return int(num * 1_000), nil + } + + num, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid bit-rate: %w", err) + } + return num, nil +} + +// ScreenRecordCommand records video from the specified device +func ScreenRecordCommand(req ScreenRecordRequest) *CommandResponse { + targetDevice, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %w", err)) + } + + // parse bit-rate (default 8M) + bitRateStr := req.BitRate + if bitRateStr == "" { + bitRateStr = "8M" + } + + bitRate, err := parseBitRate(bitRateStr) + if err != nil { + return NewErrorResponse(fmt.Errorf("invalid bit-rate '%s': %w", req.BitRate, err)) + } + + // validate time-limit (default 300, max 300) + timeLimit := req.TimeLimit + if timeLimit <= 0 { + timeLimit = 300 + } + if timeLimit > 300 { + return NewErrorResponse(fmt.Errorf("time-limit must be at most 300 seconds, got %d", timeLimit)) + } + + // determine output path + outputPath := req.OutputPath + if outputPath == "" { + timestamp := time.Now().Format("20060102150405") + safeDeviceID := strings.ReplaceAll(targetDevice.ID(), ":", "_") + fileName := fmt.Sprintf("screenrecord-%s-%s.mp4", safeDeviceID, timestamp) + outputPath, err = filepath.Abs("./" + fileName) + if err != nil { + return NewErrorResponse(fmt.Errorf("error creating default path: %w", err)) + } + } else { + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return NewErrorResponse(fmt.Errorf("invalid output path: %w", err)) + } + } + + // start agent + err = targetDevice.StartAgent(devices.StartAgentConfig{ + Hook: GetShutdownHook(), + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %w", targetDevice.ID(), err)) + } + + // record video + err = targetDevice.RecordVideo(devices.RecordVideoConfig{ + BitRate: bitRate, + TimeLimit: timeLimit, + OutputPath: outputPath, + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("error recording video: %w", err)) + } + + return NewSuccessResponse(ScreenRecordResponse{ + FilePath: outputPath, + }) +} diff --git a/devices/android.go b/devices/android.go index e65f981..2875e05 100644 --- a/devices/android.go +++ b/devices/android.go @@ -7,11 +7,13 @@ import ( "fmt" "os" "os/exec" + "os/signal" "path/filepath" "regexp" "runtime" "strconv" "strings" + "syscall" "time" "github.com/mobile-next/mobilecli/devices/wda" @@ -1235,3 +1237,75 @@ func (d *AndroidDevice) SetOrientation(orientation string) error { return nil } + +func (d *AndroidDevice) RecordVideo(config RecordVideoConfig) error { + remotePath := fmt.Sprintf("/sdcard/Download/mobilecli-rec-%d.mp4", time.Now().UnixNano()) + + // build adb shell screenrecord command + args := []string{ + "shell", "screenrecord", + "--bit-rate", strconv.Itoa(config.BitRate), + "--time-limit", strconv.Itoa(config.TimeLimit), + remotePath, + } + + if config.OnProgress != nil { + config.OnProgress("Recording video") + } + utils.Verbose("Running: adb %s", strings.Join(append([]string{"-s", d.getAdbIdentifier()}, args...), " ")) + + // run screenrecord; it blocks until time-limit or the process is killed + cmdArgs := append([]string{"-s", d.getAdbIdentifier()}, args...) + cmd := exec.Command(getAdbPath(), cmdArgs...) + + // capture stderr for diagnostics + cmd.Stderr = os.Stderr + + // set up signal handling so Ctrl+C stops recording gracefully + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigChan) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start screenrecord: %w", err) + } + + // wait for either process exit or signal + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + select { + case <-sigChan: + // user interrupted — send SIGINT to adb so screenrecord finalizes the mp4 + utils.Verbose("Interrupt received, stopping recording...") + _ = cmd.Process.Signal(syscall.SIGINT) + <-done // wait for process to finish writing + case err := <-done: + if err != nil { + // screenrecord may exit non-zero on some devices; log but continue + utils.Verbose("screenrecord exited with: %v", err) + } + } + + if config.OnProgress != nil { + config.OnProgress("Pulling recorded video") + } + + // pull the file from device + pullOutput, err := d.runAdbCommand("pull", remotePath, config.OutputPath) + if err != nil { + return fmt.Errorf("failed to pull recorded file: %w\n%s", err, string(pullOutput)) + } + + utils.Verbose("Pulled recording to %s", config.OutputPath) + + // remove temp file from device + rmOutput, err := d.runAdbCommand("shell", "rm", remotePath) + if err != nil { + utils.Verbose("Warning: failed to remove temp file on device: %v\n%s", err, string(rmOutput)) + } + + return nil +} diff --git a/devices/common.go b/devices/common.go index ae83eed..29668b3 100644 --- a/devices/common.go +++ b/devices/common.go @@ -18,6 +18,14 @@ const ( DefaultFramerate = 30 ) +// RecordVideoConfig contains configuration for video recording +type RecordVideoConfig struct { + BitRate int + TimeLimit int + OutputPath string + OnProgress func(message string) +} + // ScreenCaptureConfig contains configuration for screen capture operations type ScreenCaptureConfig struct { Format string @@ -67,6 +75,7 @@ type ControllableDevice interface { UninstallApp(packageName string) (*InstalledAppInfo, error) Info() (*FullDeviceInfo, error) StartScreenCapture(config ScreenCaptureConfig) error + RecordVideo(config RecordVideoConfig) error DumpSource() ([]ScreenElement, error) DumpSourceRaw() (any, error) GetOrientation() (string, error) diff --git a/devices/ios.go b/devices/ios.go index df1f924..7fc85dd 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -1171,6 +1171,10 @@ func (d IOSDevice) SetOrientation(orientation string) error { return d.wdaClient.SetOrientation(orientation) } +func (d IOSDevice) RecordVideo(config RecordVideoConfig) error { + return fmt.Errorf("not yet implemented") +} + // DeviceKitInfo contains information about the started DeviceKit session type DeviceKitInfo struct { HTTPPort int `json:"httpPort"` diff --git a/devices/remote.go b/devices/remote.go index 3818ee6..59d2618 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -301,3 +301,7 @@ func (r *RemoteDevice) UninstallApp(packageName string) (*InstalledAppInfo, erro func (r *RemoteDevice) StartScreenCapture(config ScreenCaptureConfig) error { return fmt.Errorf("screen capture is not supported on remote devices") } + +func (r *RemoteDevice) RecordVideo(config RecordVideoConfig) error { + return fmt.Errorf("not yet implemented") +} diff --git a/devices/simulator.go b/devices/simulator.go index 3123aa4..27bc507 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -959,3 +959,7 @@ func (s SimulatorDevice) GetOrientation() (string, error) { func (s SimulatorDevice) SetOrientation(orientation string) error { return s.wdaClient.SetOrientation(orientation) } + +func (s SimulatorDevice) RecordVideo(config RecordVideoConfig) error { + return fmt.Errorf("not yet implemented") +}