diff --git a/.gitignore b/.gitignore index 20c9a5a..b20391b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ go.work # Screenshots and recordings screenshot_*.png recording_*.mp4 +*.mp4 +*.gif +*.png # Temporary files /tmp/ \ No newline at end of file diff --git a/cmd/constants.go b/cmd/constants.go new file mode 100644 index 0000000..f4a6e60 --- /dev/null +++ b/cmd/constants.go @@ -0,0 +1,23 @@ +package cmd + +const ( + DarwinOS = "darwin" + StateBooted = "Booted" + StateShutdown = "Shutdown" + TypeIOSSimulator = "iOS Simulator" + TypeAndroidEmulator = "Android Emulator" + PlatformIOS = "ios" + PlatformAndroid = "android" + ExtPNG = ".png" + ExtMP4 = ".mp4" + ExtGIF = ".gif" + CmdXCrun = "xcrun" + CmdSimctl = "simctl" + CmdAdb = "adb" + CmdEmulator = "emulator" + CmdAvdManager = "avdmanager" + CmdFFmpeg = "ffmpeg" + CmdOsaScript = "osascript" + PrefixScreenshot = "screenshot" + PrefixRecording = "recording" +) diff --git a/cmd/device.go b/cmd/device.go index aa4a9cd..2f58e4b 100644 --- a/cmd/device.go +++ b/cmd/device.go @@ -9,11 +9,6 @@ import ( "github.com/spf13/cobra" ) -const ( - darwinOS = "darwin" - bootedState = "Booted" -) - var startCmd = &cobra.Command{ Use: "start [device-name-or-udid|lts]", Aliases: []string{"s"}, @@ -24,7 +19,6 @@ Use 'lts' to start the last started device.`, Run: func(cmd *cobra.Command, args []string) { deviceID := args[0] - // Handle "lts" (last started) case if deviceID == "lts" { lastDevice, err := getLastStartedDevice() if err != nil || lastDevice == nil { @@ -36,7 +30,7 @@ Use 'lts' to start the last started device.`, deviceID = lastDevice.Name } - if runtime.GOOS == darwinOS { + if runtime.GOOS == DarwinOS { if startIOSSimulator(deviceID) { return } @@ -59,7 +53,7 @@ var stopCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { deviceID := args[0] - if runtime.GOOS == darwinOS { + if runtime.GOOS == DarwinOS { if stopIOSSimulator(deviceID) { return } @@ -82,13 +76,13 @@ var shutdownCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { deviceID := args[0] - if runtime.GOOS == "darwin" { + if runtime.GOOS == DarwinOS { if shutdownIOSSimulator(deviceID) { return } } - if stopAndroidEmulator(deviceID) { // Android doesn't distinguish between stop and shutdown + if stopAndroidEmulator(deviceID) { return } @@ -105,7 +99,7 @@ var restartCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { deviceID := args[0] - if runtime.GOOS == "darwin" { + if runtime.GOOS == DarwinOS { if restartIOSSimulator(deviceID) { return } @@ -129,7 +123,7 @@ var deleteCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { deviceID := args[0] - if runtime.GOOS == "darwin" { + if runtime.GOOS == DarwinOS { if deleteIOSSimulator(deviceID) { return } @@ -183,7 +177,7 @@ var ltsCmd = &cobra.Command{ fmt.Printf("Starting last device: %s (%s)\n", lastDevice.Name, lastDevice.Type) deviceID := lastDevice.Name - if runtime.GOOS == "darwin" { + if runtime.GOOS == DarwinOS { if startIOSSimulator(deviceID) { return } @@ -204,7 +198,7 @@ func startIOSSimulator(deviceID string) bool { } fmt.Printf("Starting iOS simulator '%s'...\n", deviceID) - cmd := exec.Command("xcrun", "simctl", "boot", device.UDID) + cmd := exec.Command(CmdXCrun, CmdSimctl, "boot", device.UDID) if err := cmd.Run(); err != nil { fmt.Printf("Error starting iOS simulator: %v\n", err) return false @@ -215,8 +209,7 @@ func startIOSSimulator(deviceID string) bool { fmt.Printf("Warning: Could not open Simulator app: %v\n", err) } - // Save as last started device with complete information - device.State = "Booted" + device.State = StateBooted if err := saveLastStartedDevice(device); err != nil { fmt.Printf("Warning: Could not save last started device: %v\n", err) } @@ -227,13 +220,13 @@ func startIOSSimulator(deviceID string) bool { } func stopIOSSimulator(deviceID string) bool { - udid := findIOSSimulatorUDID(deviceID) + udid, _ := findIOSSimulator(deviceID) if udid == "" { return false } fmt.Printf("Stopping iOS simulator '%s'...\n", deviceID) - cmd := exec.Command("xcrun", "simctl", "shutdown", udid) + cmd := exec.Command(CmdXCrun, CmdSimctl, "shutdown", udid) if err := cmd.Run(); err != nil { fmt.Printf("Error stopping iOS simulator: %v\n", err) return false @@ -256,10 +249,10 @@ func restartIOSSimulator(deviceID string) bool { fmt.Printf("Restarting iOS simulator '%s'...\n", deviceID) - shutdownCmd := exec.Command("xcrun", "simctl", "shutdown", device.UDID) + shutdownCmd := exec.Command(CmdXCrun, CmdSimctl, "shutdown", device.UDID) _ = shutdownCmd.Run() // Ignore error if already shutdown - bootCmd := exec.Command("xcrun", "simctl", "boot", device.UDID) + bootCmd := exec.Command(CmdXCrun, CmdSimctl, "boot", device.UDID) if err := bootCmd.Run(); err != nil { fmt.Printf("Error restarting iOS simulator: %v\n", err) return false @@ -270,8 +263,7 @@ func restartIOSSimulator(deviceID string) bool { fmt.Printf("Warning: Could not open Simulator app: %v\n", err) } - // Save as last started device with complete information - device.State = "Booted" + device.State = StateBooted if err := saveLastStartedDevice(device); err != nil { fmt.Printf("Warning: Could not save last started device: %v\n", err) } @@ -285,12 +277,12 @@ func startAndroidEmulator(deviceID string) bool { if isAndroidEmulatorRunning(deviceID) { fmt.Printf("Android emulator '%s' is already running\n", deviceID) - // Save as last started device even if already running + udid, name := findRunningAndroidEmulator(deviceID) device := &Device{ - Name: deviceID, - UDID: findRunningAndroidEmulator(deviceID), - Type: "Android Emulator", - State: "Booted", + Name: name, + UDID: udid, + Type: TypeAndroidEmulator, + State: StateBooted, } if err := saveLastStartedDevice(device); err != nil { fmt.Printf("Warning: Could not save last started device: %v\n", err) @@ -304,18 +296,17 @@ func startAndroidEmulator(deviceID string) bool { } fmt.Printf("Starting Android emulator '%s'...\n", deviceID) - cmd := exec.Command("emulator", "-avd", deviceID) + cmd := exec.Command(CmdEmulator, "-avd", deviceID) if err := cmd.Start(); err != nil { fmt.Printf("Error starting Android emulator: %v\n", err) return false } - // Save as last started device device := &Device{ Name: deviceID, - UDID: "starting", // Will be updated when emulator is fully running - Type: "Android Emulator", - State: "Booted", + UDID: "starting", + Type: TypeAndroidEmulator, + State: StateBooted, } if err := saveLastStartedDevice(device); err != nil { fmt.Printf("Warning: Could not save last started device: %v\n", err) @@ -327,13 +318,13 @@ func startAndroidEmulator(deviceID string) bool { } func stopAndroidEmulator(deviceID string) bool { - runningUDID := findRunningAndroidEmulator(deviceID) - if runningUDID == "" { + udid, _ := findRunningAndroidEmulator(deviceID) + if udid == "" { return false } fmt.Printf("Stopping Android emulator '%s'...\n", deviceID) - cmd := exec.Command("adb", "-s", runningUDID, "emu", "kill") + cmd := exec.Command(CmdAdb, "-s", udid, "emu", "kill") if err := cmd.Run(); err != nil { fmt.Printf("Error stopping Android emulator: %v\n", err) return false @@ -350,12 +341,11 @@ func restartAndroidEmulator(deviceID string) bool { stopAndroidEmulator(deviceID) if startAndroidEmulator(deviceID) { - // Save as last started device device := &Device{ Name: deviceID, UDID: "restarting", - Type: "Android Emulator", - State: "Booted", + Type: TypeAndroidEmulator, + State: StateBooted, } if err := saveLastStartedDevice(device); err != nil { fmt.Printf("Warning: Could not save last started device: %v\n", err) @@ -368,19 +358,17 @@ func restartAndroidEmulator(deviceID string) bool { } func deleteIOSSimulator(deviceID string) bool { - udid := findIOSSimulatorUDID(deviceID) + udid, _ := findIOSSimulator(deviceID) if udid == "" { return false } fmt.Printf("Deleting iOS simulator '%s'...\n", deviceID) - // Shutdown the simulator if it's running - shutdownCmd := exec.Command("xcrun", "simctl", "shutdown", udid) - _ = shutdownCmd.Run() // Ignore error if already shutdown + shutdownCmd := exec.Command(CmdXCrun, CmdSimctl, "shutdown", udid) + _ = shutdownCmd.Run() - // Delete the simulator - cmd := exec.Command("xcrun", "simctl", "delete", udid) + cmd := exec.Command(CmdXCrun, CmdSimctl, "delete", udid) if err := cmd.Run(); err != nil { fmt.Printf("Error deleting iOS simulator: %v\n", err) return false @@ -398,11 +386,9 @@ func deleteAndroidEmulator(deviceID string) bool { fmt.Printf("Deleting Android emulator '%s'...\n", deviceID) - // Stop the emulator if it's running stopAndroidEmulator(deviceID) - // Delete the AVD - cmd := exec.Command("avdmanager", "delete", "avd", "-n", deviceID) + cmd := exec.Command(CmdAvdManager, "delete", "avd", "-n", deviceID) if err := cmd.Run(); err != nil { fmt.Printf("Error deleting Android emulator: %v\n", err) return false @@ -413,19 +399,24 @@ func deleteAndroidEmulator(deviceID string) bool { return true } -func findIOSSimulatorUDID(deviceID string) string { +func findIOSSimulator(deviceID string) (string, string) { if len(deviceID) == 36 && strings.Count(deviceID, "-") == 4 { - return deviceID + sims := getIOSSimulators() + for _, sim := range sims { + if sim.UDID == deviceID { + return sim.UDID, sim.Name + } + } } - simulators := getIOSSimulators() - for _, sim := range simulators { - if strings.EqualFold(sim.Name, deviceID) || sim.UDID == deviceID { - return sim.UDID + sims := getIOSSimulators() + for _, sim := range sims { + if strings.EqualFold(sim.Name, deviceID) { + return sim.UDID, sim.Name } } - return "" + return "", "" } func findIOSSimulatorByID(deviceID string) *Device { @@ -439,8 +430,19 @@ func findIOSSimulatorByID(deviceID string) *Device { return nil } +func getRunningIOSSimulator() (*iOSSimulator, error) { + sims := getIOSSimulators() + for _, sim := range sims { + if sim.State == StateBooted { + return &iOSSimulator{udid: sim.UDID, name: sim.Name}, nil + } + } + + return nil, ErrNoRunningIOSSimulator +} + func doesAndroidAVDExist(avdName string) bool { - cmd := exec.Command("emulator", "-list-avds") + cmd := exec.Command(CmdEmulator, "-list-avds") output, err := cmd.Output() if err != nil { return false @@ -457,43 +459,43 @@ func doesAndroidAVDExist(avdName string) bool { } func isAndroidEmulatorRunning(avdName string) bool { - return findRunningAndroidEmulator(avdName) != "" + udid, _ := findRunningAndroidEmulator(avdName) + return udid != "" } -func findRunningAndroidEmulator(avdName string) string { - cmd := exec.Command("adb", "devices") +func findRunningAndroidEmulator(avdName string) (string, string) { + cmd := exec.Command(CmdAdb, "devices") output, err := cmd.Output() if err != nil { - return "" + return "", "" } - lines := strings.Split(string(output), "\n") - for _, line := range lines { + lines := strings.SplitSeq(string(output), "\n") + for line := range lines { if strings.Contains(line, "emulator-") && strings.Contains(line, "device") { parts := strings.Fields(line) - if len(parts) >= 2 { + if len(parts) > 0 { emulatorID := parts[0] - nameCmd := exec.Command("adb", "-s", emulatorID, "emu", "avd", "name") + nameCmd := exec.Command(CmdAdb, "-s", emulatorID, "emu", "avd", "name") nameOutput, err := nameCmd.Output() if err == nil { actualName := strings.TrimSpace(string(nameOutput)) - if actualName == avdName { - return emulatorID + if avdName == "" || actualName == avdName { + return emulatorID, actualName } } } } } - return "" + return "", "" } -func init() { - rootCmd.AddCommand(startCmd) - rootCmd.AddCommand(stopCmd) - rootCmd.AddCommand(shutdownCmd) - rootCmd.AddCommand(restartCmd) - rootCmd.AddCommand(deleteCmd) - rootCmd.AddCommand(lastCmd) - rootCmd.AddCommand(ltsCmd) +func getRunningAndroidEmulator() (*androidEmulator, error) { + udid, name := findRunningAndroidEmulator("") // Find any running emulator + if udid != "" { + return &androidEmulator{udid: udid, name: name}, nil + } + + return nil, ErrNoRunningAndroidEmulator } diff --git a/cmd/errors.go b/cmd/errors.go new file mode 100644 index 0000000..01ed5c5 --- /dev/null +++ b/cmd/errors.go @@ -0,0 +1,13 @@ +package cmd + +import "errors" + +var ( + ErrNoRunningIOSSimulator = errors.New("no running iOS simulator found") + ErrNoRunningAndroidEmulator = errors.New("no running Android emulator found") + ErrIOSSimulatorNotRunning = errors.New("iOS simulator not found or not running") + ErrAndroidEmulatorNotRunning = errors.New("android emulator not found or not running") + ErrDeviceNotRunning = errors.New("device not found or not a running iOS simulator or Android emulator") + ErrNoActiveDevice = errors.New("no active iOS simulator or Android emulator found") + ErrFFmpegNotInstalled = errors.New("ffmpeg is not installed. Please install ffmpeg to use the GIF conversion feature") +) diff --git a/cmd/list.go b/cmd/list.go index ccb3500..a6d03e0 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -29,7 +29,7 @@ var listCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { devices := []Device{} - if runtime.GOOS == "darwin" { + if runtime.GOOS == DarwinOS { simulators := getIOSSimulators() devices = append(devices, simulators...) } else { @@ -50,17 +50,16 @@ var listCmd = &cobra.Command{ for _, device := range devices { udid := device.UDID - runtime := device.Runtime - if strings.Contains(runtime, "com.apple.CoreSimulator.SimRuntime.iOS-") { - // Extract iOS version from runtime string - parts := strings.Split(runtime, "-") + runtimeVal := device.Runtime + if strings.Contains(runtimeVal, "com.apple.CoreSimulator.SimRuntime.iOS-") { + parts := strings.Split(runtimeVal, "-") if len(parts) >= 2 { version := strings.Join(parts[len(parts)-2:], ".") - runtime = "iOS " + version + runtimeVal = "iOS " + version } } - _ = table.Append(device.Type, device.Name, device.State, udid, runtime) + _ = table.Append([]string{device.Type, device.Name, device.State, udid, runtimeVal}) } _ = table.Render() @@ -68,7 +67,7 @@ var listCmd = &cobra.Command{ } func getIOSSimulators() []Device { - cmd := exec.Command("xcrun", "simctl", "list", "devices", "--json") + cmd := exec.Command(CmdXCrun, CmdSimctl, "list", "devices", "--json") output, err := cmd.Output() if err != nil { fmt.Printf("Error getting iOS simulators: %v\n", err) @@ -90,14 +89,14 @@ func getIOSSimulators() []Device { } var devices []Device - for runtime, deviceList := range result.Devices { + for runtimeVal, deviceList := range result.Devices { for _, device := range deviceList { devices = append(devices, Device{ Name: device.Name, UDID: device.UDID, State: device.State, - Type: "iOS Simulator", - Runtime: runtime, + Type: TypeIOSSimulator, + Runtime: runtimeVal, DeviceType: device.DeviceTypeIdentifier, }) } @@ -107,63 +106,77 @@ func getIOSSimulators() []Device { } func getAndroidEmulators() []Device { - runningCmd := exec.Command("adb", "devices") - runningOutput, err := runningCmd.Output() - runningDevices := make(map[string]bool) - - if err == nil { - lines := strings.Split(string(runningOutput), "\n") - for _, line := range lines { - if strings.Contains(line, "emulator-") && strings.Contains(line, "device") { - parts := strings.Fields(line) - if len(parts) >= 2 { - runningDevices[parts[0]] = true - } - } + avdCmd := exec.Command(CmdEmulator, "-list-avds") + avdOutput, err := avdCmd.Output() + if err != nil { + // Emulator command might not be in path, but adb might work. + // We can proceed and just list running devices. + fmt.Printf("Could not run 'emulator -list-avds': %v. Only running emulators will be listed.\n", err) + } + avdLines := strings.Split(strings.TrimSpace(string(avdOutput)), "\n") + avdMap := make(map[string]bool) + for _, line := range avdLines { + trimmedLine := strings.TrimSpace(line) + if trimmedLine != "" { + avdMap[trimmedLine] = true } } - cmd := exec.Command("emulator", "-list-avds") - output, err := cmd.Output() + adbCmd := exec.Command(CmdAdb, "devices") + adbOutput, err := adbCmd.Output() if err != nil { - return []Device{} + devices := make([]Device, 0, len(avdMap)) + for avd := range avdMap { + devices = append(devices, Device{ + Name: avd, + UDID: "offline", + State: StateShutdown, + Type: TypeAndroidEmulator, + Runtime: "Android", + }) + } + + return devices } - var devices []Device - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" { - state := "Shutdown" - udid := "" - - // Check if this emulator is running - for runningUDID := range runningDevices { - if strings.Contains(runningUDID, "emulator-") { - state = "Booted" - udid = runningUDID - break + runningDevices := make(map[string]string) // map[name]udid + lines := strings.SplitSeq(string(adbOutput), "\n") + for line := range lines { + if strings.Contains(line, "emulator-") && strings.Contains(line, "device") { + parts := strings.Fields(line) + if len(parts) > 0 { + udid := parts[0] + nameCmd := exec.Command(CmdAdb, "-s", udid, "emu", "avd", "name") + nameOutput, err := nameCmd.Output() + if err == nil { + name := strings.TrimSpace(string(nameOutput)) + runningDevices[name] = udid } } + } + } - if udid == "" { - udid = "offline" - } + devices := make([]Device, 0, len(avdMap)+len(runningDevices)) + for name, udid := range runningDevices { + devices = append(devices, Device{ + Name: name, + UDID: udid, + State: StateBooted, + Type: TypeAndroidEmulator, + Runtime: "Android", + }) + delete(avdMap, name) + } - devices = append(devices, Device{ - Name: line, - UDID: udid, - State: state, - Type: "Android Emulator", - Runtime: "Android", - }) - } + for avd := range avdMap { + devices = append(devices, Device{ + Name: avd, + UDID: "N/A", + State: StateShutdown, + Type: TypeAndroidEmulator, + Runtime: "Android", + }) } return devices } - -func init() { - rootCmd.AddCommand(listCmd) -} diff --git a/cmd/media.go b/cmd/media.go index 86e8e36..6b0b4eb 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -1,232 +1,356 @@ package cmd import ( + "context" + "errors" "fmt" + "os" "os/exec" + "os/signal" "path/filepath" "runtime" "strings" + "syscall" "time" "github.com/spf13/cobra" ) -var screenshotCmd = &cobra.Command{ - Use: "screenshot [device-name-or-udid] [output-file]", - Aliases: []string{"ss", "shot"}, - Short: "Take a screenshot of an iOS simulator or Android emulator", - Long: `Take a screenshot of a running iOS simulator or Android emulator and save it to a file.`, - Args: cobra.RangeArgs(1, 2), - Run: func(cmd *cobra.Command, args []string) { - deviceID := args[0] - - // Generate output filename if not provided - outputFile := "" - if len(args) > 1 { - outputFile = args[1] - } else { - timestamp := time.Now().Format("20060102_150405") - outputFile = fmt.Sprintf("screenshot_%s_%s.png", deviceID, timestamp) - } +type capturer interface { + Screenshot(outputFile string) error + Record(ctx context.Context, outputFile string) error + GetName() string +} - if runtime.GOOS == "darwin" { - if takeIOSScreenshot(deviceID, outputFile) { - return - } - } +// --- iOS Simulator ---. +type iOSSimulator struct { + udid string + name string +} - if takeAndroidScreenshot(deviceID, outputFile) { - return - } +func newIOSSimulator(deviceNameOrUDID string) (*iOSSimulator, error) { + udid, name := findIOSSimulator(deviceNameOrUDID) + if udid == "" { + return nil, ErrIOSSimulatorNotRunning + } - fmt.Printf("Device '%s' not found or failed to take screenshot\n", deviceID) - }, + return &iOSSimulator{udid: udid, name: name}, nil } -var recordCmd = &cobra.Command{ - Use: "record [device-name-or-udid] [output-file]", - Aliases: []string{"rec"}, - Short: "Record screen of an iOS simulator or Android emulator", - Long: `Start screen recording of a running iOS simulator or Android emulator.`, - Args: cobra.RangeArgs(1, 2), - Run: func(cmd *cobra.Command, args []string) { - deviceID := args[0] - - // Generate output filename if not provided - outputFile := "" - if len(args) > 1 { - outputFile = args[1] - } else { - timestamp := time.Now().Format("20060102_150405") - outputFile = fmt.Sprintf("recording_%s_%s.mp4", deviceID, timestamp) - } +func (s *iOSSimulator) Screenshot(outputFile string) error { + fmt.Printf("Taking screenshot of iOS simulator '%s'...\n", s.name) + fullPath := ensureExtension(outputFile, ExtPNG) - duration, _ := cmd.Flags().GetInt("duration") + cmd := exec.Command(CmdXCrun, CmdSimctl, "io", s.udid, "screenshot", fullPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to take iOS screenshot: %w", err) + } + fmt.Printf("Screenshot saved to: %s\n", fullPath) - if runtime.GOOS == "darwin" { - if recordIOSScreen(deviceID, outputFile, duration) { - return - } - } + return nil +} - if recordAndroidScreen(deviceID, outputFile, duration) { - return - } +func (s *iOSSimulator) Record(ctx context.Context, outputFile string) error { + fmt.Printf("Recording iOS simulator '%s' screen...\n", s.name) + fullPath := ensureExtension(outputFile, ExtMP4) - fmt.Printf("Device '%s' not found or failed to start recording\n", deviceID) - }, -} + cmd := exec.Command(CmdXCrun, CmdSimctl, "io", s.udid, "recordVideo", "--codec=h264", "--force", fullPath) -func takeIOSScreenshot(deviceID, outputFile string) bool { - udid := findIOSSimulatorUDID(deviceID) - if udid == "" { - return false + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start iOS screen recording: %w", err) } - fmt.Printf("Taking screenshot of iOS simulator '%s'...\n", deviceID) + fmt.Println("Recording started. Press Ctrl+C to stop.") + + <-ctx.Done() - if !strings.HasSuffix(strings.ToLower(outputFile), ".png") { - outputFile = strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + ".png" + if err := cmd.Process.Signal(syscall.SIGINT); err != nil { + _ = cmd.Process.Kill() + return fmt.Errorf("failed to send interrupt signal to recording process: %w", err) } - cmd := exec.Command("xcrun", "simctl", "io", udid, "screenshot", outputFile) - if err := cmd.Run(); err != nil { - fmt.Printf("Error taking iOS screenshot: %v\n", err) - return false + err := cmd.Wait() + var exitErr *exec.ExitError + if err != nil && !errors.As(err, &exitErr) { + return fmt.Errorf("error during iOS screen recording: %w", err) } - fmt.Printf("Screenshot saved to: %s\n", outputFile) + fmt.Printf("\nRecording saved to: %s\n", fullPath) - return true + return nil } -func takeAndroidScreenshot(deviceID, outputFile string) bool { - runningUDID := findRunningAndroidEmulator(deviceID) - if runningUDID == "" { - return false +func (s *iOSSimulator) GetName() string { + return s.name +} + +// --- Android Emulator --- + +type androidEmulator struct { + udid string + name string +} + +func newAndroidEmulator(deviceNameOrUDID string) (*androidEmulator, error) { + udid, name := findRunningAndroidEmulator(deviceNameOrUDID) + if udid == "" { + return nil, ErrAndroidEmulatorNotRunning } - fmt.Printf("Taking screenshot of Android emulator '%s'...\n", deviceID) + return &androidEmulator{udid: udid, name: name}, nil +} - if !strings.HasSuffix(strings.ToLower(outputFile), ".png") { - outputFile = strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + ".png" +func (e *androidEmulator) runADB(args ...string) error { + baseArgs := []string{"-s", e.udid} + cmd := exec.Command(CmdAdb, append(baseArgs, args...)...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("adb command failed: %w\nOutput: %s", err, string(output)) } + return nil +} + +func (e *androidEmulator) Screenshot(outputFile string) error { + fmt.Printf("Taking screenshot of Android emulator '%s'...\n", e.name) + fullPath := ensureExtension(outputFile, ExtPNG) devicePath := "/sdcard/screenshot.png" - screenshotCmd := exec.Command("adb", "-s", runningUDID, "shell", "screencap", "-p", devicePath) - if err := screenshotCmd.Run(); err != nil { - fmt.Printf("Error taking Android screenshot: %v\n", err) - return false - } - pullCmd := exec.Command("adb", "-s", runningUDID, "pull", devicePath, outputFile) - if err := pullCmd.Run(); err != nil { - fmt.Printf("Error pulling Android screenshot: %v\n", err) - return false + defer func() { + _ = e.runADB("shell", "rm", devicePath) + }() + + if err := e.runADB("shell", "screencap", "-p", devicePath); err != nil { + return fmt.Errorf("failed to take Android screenshot: %w", err) } - cleanupCmd := exec.Command("adb", "-s", runningUDID, "shell", "rm", devicePath) - _ = cleanupCmd.Run() // Ignore errors + if err := e.runADB("pull", devicePath, fullPath); err != nil { + return fmt.Errorf("failed to pull Android screenshot: %w", err) + } - fmt.Printf("Screenshot saved to: %s\n", outputFile) + fmt.Printf("Screenshot saved to: %s\n", fullPath) - return true + return nil } -func recordIOSScreen(deviceID, outputFile string, duration int) bool { - udid := findIOSSimulatorUDID(deviceID) - if udid == "" { - return false +func (e *androidEmulator) Record(ctx context.Context, outputFile string) error { + fmt.Printf("Recording Android emulator '%s' screen...\n", e.name) + fullPath := ensureExtension(outputFile, ExtMP4) + devicePath := "/sdcard/recording.mp4" + + defer func() { + _ = e.runADB("shell", "rm", devicePath) + }() + + args := []string{"-s", e.udid, "shell", "screenrecord", devicePath} + cmd := exec.Command(CmdAdb, args...) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start Android screen recording: %w", err) } - fmt.Printf("Recording iOS simulator '%s' screen...\n", deviceID) + fmt.Println("Recording started. Press Ctrl+C to stop.") - if !strings.HasSuffix(strings.ToLower(outputFile), ".mp4") { - outputFile = strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + ".mp4" + <-ctx.Done() + + if err := cmd.Process.Signal(syscall.SIGINT); err != nil { + _ = cmd.Process.Kill() + return fmt.Errorf("failed to send interrupt signal to adb process: %w", err) } - args := []string{"simctl", "io", udid, "recordVideo"} - if duration > 0 { - fmt.Printf("Recording for %d seconds...\n", duration) - // Note: simctl doesn't have a built-in duration option, so we'll need to handle this differently - // For now, we'll start recording and let the user stop it manually + err := cmd.Wait() + var exitErr *exec.ExitError + if err != nil && !errors.As(err, &exitErr) { + return fmt.Errorf("error during Android screen recording: %w", err) } - args = append(args, outputFile) - cmd := exec.Command("xcrun", args...) + if err := e.runADB("pull", devicePath, fullPath); err != nil { + return fmt.Errorf("failed to pull Android recording: %w", err) + } - if duration > 0 { - if err := cmd.Start(); err != nil { - fmt.Printf("Error starting iOS screen recording: %v\n", err) - return false - } + fmt.Printf("\nRecording saved to: %s\n", fullPath) - time.Sleep(time.Duration(duration) * time.Second) + return nil +} - if err := cmd.Process.Kill(); err != nil { - fmt.Printf("Error stopping recording: %v\n", err) - } +func (e *androidEmulator) GetName() string { + return e.name +} - _ = cmd.Wait() - } else { - fmt.Println("Press Ctrl+C to stop recording...") - if err := cmd.Run(); err != nil { - fmt.Printf("Error recording iOS screen: %v\n", err) - return false - } +func getCapturer(deviceID string) (capturer, error) { + if deviceID == "" { + return getActiveDevice() } - fmt.Printf("Recording saved to: %s\n", outputFile) + if runtime.GOOS == DarwinOS { + if sim, err := newIOSSimulator(deviceID); err == nil { + return sim, nil + } + } + if emu, err := newAndroidEmulator(deviceID); err == nil { + return emu, nil + } - return true + return nil, ErrDeviceNotRunning } -func recordAndroidScreen(deviceID, outputFile string, duration int) bool { - runningUDID := findRunningAndroidEmulator(deviceID) - if runningUDID == "" { - return false +func getActiveDevice() (capturer, error) { + if runtime.GOOS == DarwinOS { + if sim, err := getRunningIOSSimulator(); err == nil { + fmt.Printf("Active device found: iOS Simulator '%s'\n", sim.name) + return sim, nil + } } - fmt.Printf("Recording Android emulator '%s' screen...\n", deviceID) - - if !strings.HasSuffix(strings.ToLower(outputFile), ".mp4") { - outputFile = strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + ".mp4" + if emu, err := getRunningAndroidEmulator(); err == nil { + fmt.Printf("Active device found: Android Emulator '%s'\n", emu.name) + return emu, nil } - devicePath := "/sdcard/recording.mp4" + return nil, ErrNoActiveDevice +} + +func handleRecording(c capturer, outputFile string, duration int, convertToGif, shouldCopy bool) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - args := []string{"-s", runningUDID, "shell", "screenrecord"} if duration > 0 { - args = append(args, "--time-limit", fmt.Sprintf("%d", duration)) fmt.Printf("Recording for %d seconds...\n", duration) - } else { - fmt.Println("Press Ctrl+C to stop recording...") + time.AfterFunc(time.Duration(duration)*time.Second, cancel) } - args = append(args, devicePath) - cmd := exec.Command("adb", args...) - if err := cmd.Run(); err != nil { - fmt.Printf("Error recording Android screen: %v\n", err) - return false + // Handle Ctrl+C signal for graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("\nStopping recording...") + cancel() + }() + + err := c.Record(ctx, outputFile) + if err != nil { + return err } - pullCmd := exec.Command("adb", "-s", runningUDID, "pull", devicePath, outputFile) - if err := pullCmd.Run(); err != nil { - fmt.Printf("Error pulling Android recording: %v\n", err) - return false + finalPath := outputFile + if convertToGif { + gifPath := strings.TrimSuffix(outputFile, ExtMP4) + ExtGIF + if err := convertToGIF(outputFile, gifPath); err != nil { + return err + } + finalPath = gifPath + + if err := os.Remove(outputFile); err != nil { + fmt.Printf("Warning: could not remove original MP4 file: %v\n", err) + } } - cleanupCmd := exec.Command("adb", "-s", runningUDID, "shell", "rm", devicePath) - _ = cleanupCmd.Run() // Ignore errors + if shouldCopy { + if err := copyFileToClipboard(finalPath); err != nil { + fmt.Printf("Warning: could not copy to clipboard: %v\n", err) + } else { + fileType := strings.ToUpper(strings.TrimPrefix(filepath.Ext(finalPath), ".")) + fmt.Printf("%s file copied to clipboard.\n", fileType) + } + } + + return nil +} - fmt.Printf("Recording saved to: %s\n", outputFile) +// --- Cobra --- - return true +var screenshotCmd = &cobra.Command{ + Use: "screenshot [device-name-or-udid] [output-file]", + Aliases: []string{"ss", "shot"}, + Short: "Take a screenshot of a device", + Long: `Take a screenshot of a running iOS simulator or Android emulator and save it to a file. If no device is specified, it will try to find the active one.`, + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + var deviceID, outputFile string + if len(args) > 0 { + _, err := newIOSSimulator(args[0]) + isDevice := err == nil + if !isDevice { + _, err = newAndroidEmulator(args[0]) + isDevice = err == nil + } + + if isDevice { + deviceID = args[0] + if len(args) > 1 { + outputFile = args[1] + } + } else { + outputFile = args[0] + } + } + + c, err := getCapturer(deviceID) + if err != nil { + return err + } + + if outputFile == "" { + outputFile = generateFilename(PrefixScreenshot, c.GetName(), ExtPNG) + } + + if err := c.Screenshot(outputFile); err != nil { + return err + } + + if shouldCopy, _ := cmd.Flags().GetBool("copy"); shouldCopy { + if err := copyFileToClipboard(outputFile); err != nil { + fmt.Printf("Warning: could not copy to clipboard: %v\n", err) + } else { + fmt.Println("Screenshot copied to clipboard.") + } + } + + return nil + }, } -func init() { - rootCmd.AddCommand(screenshotCmd) - rootCmd.AddCommand(recordCmd) +var recordCmd = &cobra.Command{ + Use: "record [device-name-or-udid] [output-file]", + Aliases: []string{"rec"}, + Short: "Record screen of a device", + Long: `Start screen recording of a running iOS simulator or Android emulator. +If no device is specified, it will try to find the active one. +The recording can be stopped by pressing Ctrl+C or by specifying a duration.`, + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + var deviceID, outputFile string + if len(args) > 0 { + _, err := newIOSSimulator(args[0]) + isDevice := err == nil + if !isDevice { + _, err = newAndroidEmulator(args[0]) + isDevice = err == nil + } + + if isDevice { + deviceID = args[0] + if len(args) > 1 { + outputFile = args[1] + } + } else { + outputFile = args[0] + } + } - recordCmd.Flags().IntP("duration", "d", 0, "Recording duration in seconds (0 for manual stop)") + c, err := getCapturer(deviceID) + if err != nil { + return err + } + + if outputFile == "" { + outputFile = generateFilename(PrefixRecording, c.GetName(), ExtMP4) + } + + duration, _ := cmd.Flags().GetInt("duration") + convertToGif, _ := cmd.Flags().GetBool("gif") + shouldCopy, _ := cmd.Flags().GetBool("copy") + + return handleRecording(c, outputFile, duration, convertToGif, shouldCopy) + }, } diff --git a/cmd/root.go b/cmd/root.go index 871fa7b..4e9b589 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -49,4 +49,23 @@ func Execute() error { func init() { rootCmd.Flags().BoolP("version", "v", false, "Show version information") + + rootCmd.AddCommand(listCmd) + rootCmd.AddCommand(startCmd) + rootCmd.AddCommand(stopCmd) + rootCmd.AddCommand(shutdownCmd) + rootCmd.AddCommand(restartCmd) + rootCmd.AddCommand(deleteCmd) + rootCmd.AddCommand(lastCmd) + rootCmd.AddCommand(ltsCmd) + rootCmd.AddCommand(screenshotCmd) + rootCmd.AddCommand(recordCmd) + + // Screenshot flags + screenshotCmd.Flags().BoolP("copy", "c", false, "Copy the screenshot to the clipboard") + + // Record flags + recordCmd.Flags().IntP("duration", "d", 0, "Duration of the recording in seconds (default: unlimited)") + recordCmd.Flags().BoolP("gif", "g", false, "Convert the recording to a GIF") + recordCmd.Flags().BoolP("copy", "c", false, "Copy the recording to the clipboard") } diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 0000000..2d2f7eb --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "fmt" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/atotto/clipboard" +) + +// --- File and Path Helpers --- + +func generateFilename(prefix, deviceID, extension string) string { + timestamp := time.Now().Format("20060102_150405") + sanitizedDeviceID := strings.ReplaceAll(deviceID, " ", "_") + return fmt.Sprintf("%s_%s_%s%s", prefix, sanitizedDeviceID, timestamp, extension) +} + +func ensureExtension(filename, ext string) string { + if !strings.HasSuffix(strings.ToLower(filename), ext) { + return strings.TrimSuffix(filename, filepath.Ext(filename)) + ext + } + + return filename +} + +// --- Clipboard Operations --- + +func copyToClipboard(text string) error { + return clipboard.WriteAll(text) +} + +func copyFileToClipboard(filePath string) error { + var cmd *exec.Cmd + ext := strings.ToLower(filepath.Ext(filePath)) + + switch runtime.GOOS { + case DarwinOS: + var script string + if ext == ExtPNG { + script = fmt.Sprintf("set the clipboard to (read (POSIX file \"%s\") as TIFF picture)", filePath) + } else { + script = fmt.Sprintf("set the clipboard to POSIX file \"%s\"", filePath) + } + cmd = exec.Command(CmdOsaScript, "-e", script) + default: + return copyToClipboard(filePath) + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to copy file to clipboard: %w", err) + } + + return nil +} + +// --- Command Execution --- + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +// --- Video Conversion --- + +func convertToGIF(inputFile, outputFile string) error { + if !commandExists(CmdFFmpeg) { + return ErrFFmpegNotInstalled + } + + fmt.Println("Converting to GIF...") + cmd := exec.Command(CmdFFmpeg, "-i", inputFile, "-vf", "fps=10,scale=480:-1:flags=lanczos", "-c", "gif", "-f", "gif", outputFile) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to convert to GIF: %w\nOutput: %s", err, string(output)) + } + fmt.Printf("GIF saved to: %s\n", outputFile) + + return nil +} diff --git a/go.mod b/go.mod index d565bc6..44f42ee 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module github.com/annurdien/sim-cli go 1.24.4 -require github.com/spf13/cobra v1.9.1 +require ( + github.com/atotto/clipboard v0.1.4 + github.com/olekukonko/tablewriter v1.0.8 + github.com/spf13/cobra v1.9.1 +) require ( github.com/fatih/color v1.15.0 // indirect @@ -12,7 +16,6 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 // indirect github.com/olekukonko/ll v0.0.8 // indirect - github.com/olekukonko/tablewriter v1.0.8 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.6 // indirect golang.org/x/sys v0.12.0 // indirect diff --git a/go.sum b/go.sum index d506d80..88458c2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= @@ -8,12 +10,17 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 h1:r3FaAI0NZK3hSmtTDrBVREhKULp8oUeqLT5Eyl2mSPo= github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.0.8 h1:sbGZ1Fx4QxJXEqL/6IG8GEFnYojUSQ45dJVwN2FH2fc= github.com/olekukonko/ll v0.0.8/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/olekukonko/tablewriter v1.0.8 h1:f6wJzHg4QUtJdvrVPKco4QTrAylgaU0+b9br/lJxEiQ= github.com/olekukonko/tablewriter v1.0.8/go.mod h1:H428M+HzoUXC6JU2Abj9IT9ooRmdq9CxuDmKMtrOCMs= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=