From a04766cbe5ed7309cdac980ce68be9abb9ee724d Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 23 Jul 2025 12:45:45 +0700 Subject: [PATCH 1/3] ref: refactor device management commands to improve structure and add clipboard functionality - Consolidated iOS simulator and Android emulator handling into dedicated structs with common interface for screenshot and recording operations. - Enhanced screenshot and recording commands to support active device detection and improved error handling. - Introduced utility functions for file management, clipboard operations, and command execution. - Updated command flags for screenshot and recording commands to include options for copying file paths to clipboard and converting recordings to GIF format. - Added context handling for recording operations to allow graceful termination. - Updated dependencies in go.mod to include clipboard package for clipboard operations. --- cmd/constants.go | 23 +++ cmd/device.go | 128 +++++++++------ cmd/list.go | 121 ++++++++------ cmd/media.go | 412 ++++++++++++++++++++++++++++++----------------- cmd/utils.go | 79 +++++++++ go.mod | 7 +- go.sum | 7 + 7 files changed, 520 insertions(+), 257 deletions(-) create mode 100644 cmd/constants.go create mode 100644 cmd/utils.go 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..2a6d9ac 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"}, @@ -36,7 +31,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 +54,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,7 +77,7 @@ 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 } @@ -105,7 +100,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 +124,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 +178,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 +199,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 @@ -216,7 +211,7 @@ func startIOSSimulator(deviceID string) bool { } // 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 +222,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 +251,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 @@ -271,7 +266,7 @@ func restartIOSSimulator(deviceID string) bool { } // 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) } @@ -286,11 +281,12 @@ func startAndroidEmulator(deviceID string) bool { 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,7 +300,7 @@ 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 @@ -314,8 +310,8 @@ func startAndroidEmulator(deviceID string) bool { device := &Device{ Name: deviceID, UDID: "starting", // Will be updated when emulator is fully running - 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) @@ -327,13 +323,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 @@ -354,8 +350,8 @@ func restartAndroidEmulator(deviceID string) bool { 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,7 +364,7 @@ func restartAndroidEmulator(deviceID string) bool { } func deleteIOSSimulator(deviceID string) bool { - udid := findIOSSimulatorUDID(deviceID) + udid, _ := findIOSSimulator(deviceID) if udid == "" { return false } @@ -376,11 +372,11 @@ func deleteIOSSimulator(deviceID string) bool { fmt.Printf("Deleting iOS simulator '%s'...\n", deviceID) // Shutdown the simulator if it's running - shutdownCmd := exec.Command("xcrun", "simctl", "shutdown", udid) + shutdownCmd := exec.Command(CmdXCrun, CmdSimctl, "shutdown", udid) _ = shutdownCmd.Run() // Ignore error if already shutdown // 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 @@ -402,7 +398,7 @@ func deleteAndroidEmulator(deviceID string) bool { 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 +409,27 @@ func deleteAndroidEmulator(deviceID string) bool { return true } -func findIOSSimulatorUDID(deviceID string) string { +// findIOSSimulator returns the UDID and name of a simulator by its name or UDID. +func findIOSSimulator(deviceID string) (string, string) { if len(deviceID) == 36 && strings.Count(deviceID, "-") == 4 { - return deviceID + // It's likely a UDID, find its name + 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 + // It's a name, find its 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 +443,18 @@ 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, fmt.Errorf("no running iOS simulator found") +} + 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,35 +471,47 @@ 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") +// findRunningAndroidEmulator returns the UDID and name of a running emulator. +// If avdName is empty, it returns the first running emulator it finds. +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 { 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 a name is specified, match it. Otherwise, return the first one. + if avdName == "" || actualName == avdName { + return emulatorID, actualName } } } } } - return "" + return "", "" +} + +func getRunningAndroidEmulator() (*androidEmulator, error) { + udid, name := findRunningAndroidEmulator("") // Find any running emulator + if udid != "" { + return &androidEmulator{udid: udid, name: name}, nil + } + return nil, fmt.Errorf("no running Android emulator found") } func init() { diff --git a/cmd/list.go b/cmd/list.go index ccb3500..fa6ac96 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,25 +50,25 @@ var listCmd = &cobra.Command{ for _, device := range devices { udid := device.UDID - runtime := device.Runtime - if strings.Contains(runtime, "com.apple.CoreSimulator.SimRuntime.iOS-") { + runtimeVal := device.Runtime + if strings.Contains(runtimeVal, "com.apple.CoreSimulator.SimRuntime.iOS-") { // Extract iOS version from runtime string - parts := strings.Split(runtime, "-") + 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() + table.Render() }, } 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 +90,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,58 +107,75 @@ 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. + } + 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{} + var devices []Device + 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") - + runningDevices := make(map[string]string) // map[name]udid + lines := strings.Split(string(adbOutput), "\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 + 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" - } + var devices []Device + for name, udid := range runningDevices { + devices = append(devices, Device{ + Name: name, + UDID: udid, + State: StateBooted, + Type: TypeAndroidEmulator, + Runtime: "Android", + }) + // Remove from avdMap so we don't list it twice + 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: "offline", + State: StateShutdown, + Type: TypeAndroidEmulator, + Runtime: "Android", + }) } return devices diff --git a/cmd/media.go b/cmd/media.go index 86e8e36..65ebf61 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -1,232 +1,340 @@ package cmd import ( + "context" "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, fmt.Errorf("iOS simulator '%s' not found or not running", deviceNameOrUDID) + } + return &iOSSimulator{udid: udid, name: name}, nil +} - fmt.Printf("Device '%s' not found or failed to take screenshot\n", deviceID) - }, +func (s *iOSSimulator) Screenshot(outputFile string) error { + fmt.Printf("Taking screenshot of iOS simulator '%s'...\n", s.name) + fullPath := ensureExtension(outputFile, ExtPNG) + + 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) + return 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) Record(ctx context.Context, outputFile string) error { + fmt.Printf("Recording iOS simulator '%s' screen...\n", s.name) + fullPath := ensureExtension(outputFile, ExtMP4) - duration, _ := cmd.Flags().GetInt("duration") + cmd := exec.CommandContext(ctx, CmdXCrun, CmdSimctl, "io", s.udid, "recordVideo", "--force", fullPath) - if runtime.GOOS == "darwin" { - if recordIOSScreen(deviceID, outputFile, duration) { - return - } - } + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start iOS screen recording: %w", err) + } - if recordAndroidScreen(deviceID, outputFile, duration) { - return - } + fmt.Println("Recording started. Press Ctrl+C to stop.") - fmt.Printf("Device '%s' not found or failed to start recording\n", deviceID) - }, + err := cmd.Wait() + + if ctx.Err() == nil && err != nil { + // If context is not done, but we have an error, it's a real error. + return fmt.Errorf("error during iOS screen recording: %w", err) + } + + fmt.Printf("\nRecording saved to: %s\n", fullPath) + return nil +} + +// --- Android Emulator --- + +type androidEmulator struct { + udid string + name string } -func takeIOSScreenshot(deviceID, outputFile string) bool { - udid := findIOSSimulatorUDID(deviceID) +func newAndroidEmulator(deviceNameOrUDID string) (*androidEmulator, error) { + udid, name := findRunningAndroidEmulator(deviceNameOrUDID) if udid == "" { - return false + return nil, fmt.Errorf("android emulator '%s' not found or not running", deviceNameOrUDID) } + return &androidEmulator{udid: udid, name: name}, nil +} - fmt.Printf("Taking screenshot of iOS simulator '%s'...\n", deviceID) - - 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 +} - 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 +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" + + defer 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) } - fmt.Printf("Screenshot saved to: %s\n", outputFile) + if err := e.runADB("pull", devicePath, fullPath); err != nil { + return fmt.Errorf("failed to pull Android screenshot: %w", err) + } - return true + fmt.Printf("Screenshot saved to: %s\n", fullPath) + return nil } -func takeAndroidScreenshot(deviceID, outputFile string) bool { - runningUDID := findRunningAndroidEmulator(deviceID) - if runningUDID == "" { - 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" - fmt.Printf("Taking screenshot of Android emulator '%s'...\n", deviceID) + defer e.runADB("shell", "rm", devicePath) - if !strings.HasSuffix(strings.ToLower(outputFile), ".png") { - outputFile = strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + ".png" + args := []string{"-s", e.udid, "shell", "screenrecord", devicePath} + cmd := exec.CommandContext(ctx, CmdAdb, args...) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start Android screen recording: %w", err) } - 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 + fmt.Println("Recording started. Press Ctrl+C to stop.") + + err := cmd.Wait() + if ctx.Err() == nil && err != nil { + return fmt.Errorf("error during Android screen recording: %w", err) } - 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 + if err := e.runADB("pull", devicePath, fullPath); err != nil { + return fmt.Errorf("failed to pull Android recording: %w", err) } - cleanupCmd := exec.Command("adb", "-s", runningUDID, "shell", "rm", devicePath) - _ = cleanupCmd.Run() // Ignore errors + fmt.Printf("\nRecording saved to: %s\n", fullPath) + return nil +} - fmt.Printf("Screenshot saved to: %s\n", outputFile) +func getCapturer(deviceID string) (capturer, error) { + if deviceID == "" { + return getActiveDevice() + } - return true + 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 nil, fmt.Errorf("device '%s' not found or not a running iOS simulator or Android emulator", deviceID) } -func recordIOSScreen(deviceID, outputFile string, duration int) bool { - udid := findIOSSimulatorUDID(deviceID) - if udid == "" { - 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 iOS simulator '%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 } - args := []string{"simctl", "io", udid, "recordVideo"} + return nil, fmt.Errorf("no active iOS simulator or Android emulator found") +} + +func handleRecording(c capturer, outputFile string, duration int, convertToGif, copy bool) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + 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 + time.AfterFunc(time.Duration(duration)*time.Second, cancel) } - args = append(args, outputFile) - cmd := exec.Command("xcrun", args...) + // 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 + } - if duration > 0 { - if err := cmd.Start(); err != nil { - fmt.Printf("Error starting iOS screen 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 - time.Sleep(time.Duration(duration) * time.Second) - - if err := cmd.Process.Kill(); err != nil { - fmt.Printf("Error stopping recording: %v\n", err) + if err := os.Remove(outputFile); err != nil { + fmt.Printf("Warning: could not remove original MP4 file: %v\n", err) } + } - _ = 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 + if copy { + 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) } } - fmt.Printf("Recording saved to: %s\n", outputFile) - - return true + return nil } -func recordAndroidScreen(deviceID, outputFile string, duration int) bool { - runningUDID := findRunningAndroidEmulator(deviceID) - if runningUDID == "" { - return false - } +// --- Cobra --- - fmt.Printf("Recording Android emulator '%s' screen...\n", deviceID) +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 !strings.HasSuffix(strings.ToLower(outputFile), ".mp4") { - outputFile = strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + ".mp4" - } + if isDevice { + deviceID = args[0] + if len(args) > 1 { + outputFile = args[1] + } + } else { + outputFile = args[0] + } + } - devicePath := "/sdcard/recording.mp4" + c, err := getCapturer(deviceID) + if err != nil { + return err + } - 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...") - } - args = append(args, devicePath) + if outputFile == "" { + outputFile = generateFilename(PrefixScreenshot, c.GetName(), ExtPNG) + } - cmd := exec.Command("adb", args...) - if err := cmd.Run(); err != nil { - fmt.Printf("Error recording Android screen: %v\n", err) - return false - } + if err := c.Screenshot(outputFile); 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 - } + if copy, _ := cmd.Flags().GetBool("copy"); copy { + 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 + }, +} + +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 + } - cleanupCmd := exec.Command("adb", "-s", runningUDID, "shell", "rm", devicePath) - _ = cleanupCmd.Run() // Ignore errors + 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 + } - fmt.Printf("Recording saved to: %s\n", outputFile) + if outputFile == "" { + outputFile = generateFilename(PrefixRecording, c.GetName(), ExtMP4) + } + + duration, _ := cmd.Flags().GetInt("duration") + convertToGif, _ := cmd.Flags().GetBool("gif") + copy, _ := cmd.Flags().GetBool("copy") - return true + return handleRecording(c, outputFile, duration, convertToGif, copy) + }, +} + +func (s *iOSSimulator) GetName() string { + return s.name +} + +func (e *androidEmulator) GetName() string { + return e.name } func init() { rootCmd.AddCommand(screenshotCmd) rootCmd.AddCommand(recordCmd) - recordCmd.Flags().IntP("duration", "d", 0, "Recording duration in seconds (0 for manual stop)") + screenshotCmd.Flags().BoolP("copy", "c", false, "Copy the file path to the clipboard") + + recordCmd.Flags().IntP("duration", "d", 0, "Recording duration in seconds (0 for manual stop via Ctrl+C)") + recordCmd.Flags().BoolP("gif", "g", false, "Convert the recording to a GIF after stopping") + recordCmd.Flags().BoolP("copy", "c", false, "Copy the file path to the clipboard after stopping") } diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 0000000..9037f11 --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,79 @@ +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 fmt.Errorf("'%s' is not installed. Please install ffmpeg to use the GIF conversion feature", CmdFFmpeg) + } + + 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= From 85db3721a7ddf52154da6310959102c4a289c2ba Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 23 Jul 2025 13:01:29 +0700 Subject: [PATCH 2/3] fix: fix corrupted video recording --- cmd/media.go | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/cmd/media.go b/cmd/media.go index 65ebf61..1ee0062 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -51,7 +51,7 @@ func (s *iOSSimulator) Record(ctx context.Context, outputFile string) error { fmt.Printf("Recording iOS simulator '%s' screen...\n", s.name) fullPath := ensureExtension(outputFile, ExtMP4) - cmd := exec.CommandContext(ctx, CmdXCrun, CmdSimctl, "io", s.udid, "recordVideo", "--force", fullPath) + cmd := exec.Command(CmdXCrun, CmdSimctl, "io", s.udid, "recordVideo", "--codec=h264", "--force", fullPath) if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start iOS screen recording: %w", err) @@ -59,11 +59,18 @@ func (s *iOSSimulator) Record(ctx context.Context, outputFile string) error { fmt.Println("Recording started. Press Ctrl+C to stop.") - err := cmd.Wait() + <-ctx.Done() + + 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) + } - if ctx.Err() == nil && err != nil { - // If context is not done, but we have an error, it's a real error. - return fmt.Errorf("error during iOS screen recording: %w", err) + err := cmd.Wait() + if err != nil { + if _, ok := err.(*exec.ExitError); !ok { + return fmt.Errorf("error during iOS screen recording: %w", err) + } } fmt.Printf("\nRecording saved to: %s\n", fullPath) @@ -121,7 +128,7 @@ func (e *androidEmulator) Record(ctx context.Context, outputFile string) error { defer e.runADB("shell", "rm", devicePath) args := []string{"-s", e.udid, "shell", "screenrecord", devicePath} - cmd := exec.CommandContext(ctx, CmdAdb, args...) + cmd := exec.Command(CmdAdb, args...) if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start Android screen recording: %w", err) @@ -129,9 +136,18 @@ func (e *androidEmulator) Record(ctx context.Context, outputFile string) error { fmt.Println("Recording started. Press Ctrl+C to stop.") + <-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) + } + err := cmd.Wait() - if ctx.Err() == nil && err != nil { - return fmt.Errorf("error during Android screen recording: %w", err) + if err != nil { + if _, ok := err.(*exec.ExitError); !ok { + return fmt.Errorf("error during Android screen recording: %w", err) + } } if err := e.runADB("pull", devicePath, fullPath); err != nil { From 31a908063675c0ecbc5219d19b92283b4d78cbfd Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Wed, 23 Jul 2025 13:19:57 +0700 Subject: [PATCH 3/3] fix: fix linting and add errors constant --- .gitignore | 3 ++ cmd/device.go | 40 +++++-------------------- cmd/errors.go | 13 ++++++++ cmd/list.go | 22 ++++++-------- cmd/media.go | 82 +++++++++++++++++++++++++-------------------------- cmd/root.go | 19 ++++++++++++ cmd/utils.go | 5 +++- 7 files changed, 97 insertions(+), 87 deletions(-) create mode 100644 cmd/errors.go 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/device.go b/cmd/device.go index 2a6d9ac..2f58e4b 100644 --- a/cmd/device.go +++ b/cmd/device.go @@ -19,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 { @@ -83,7 +82,7 @@ var shutdownCmd = &cobra.Command{ } } - if stopAndroidEmulator(deviceID) { // Android doesn't distinguish between stop and shutdown + if stopAndroidEmulator(deviceID) { return } @@ -210,7 +209,6 @@ 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 = StateBooted if err := saveLastStartedDevice(device); err != nil { fmt.Printf("Warning: Could not save last started device: %v\n", err) @@ -265,7 +263,6 @@ 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 = StateBooted if err := saveLastStartedDevice(device); err != nil { fmt.Printf("Warning: Could not save last started device: %v\n", err) @@ -280,7 +277,6 @@ 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: name, @@ -306,10 +302,9 @@ func startAndroidEmulator(deviceID string) bool { return false } - // Save as last started device device := &Device{ Name: deviceID, - UDID: "starting", // Will be updated when emulator is fully running + UDID: "starting", Type: TypeAndroidEmulator, State: StateBooted, } @@ -346,7 +341,6 @@ func restartAndroidEmulator(deviceID string) bool { stopAndroidEmulator(deviceID) if startAndroidEmulator(deviceID) { - // Save as last started device device := &Device{ Name: deviceID, UDID: "restarting", @@ -371,11 +365,9 @@ func deleteIOSSimulator(deviceID string) bool { fmt.Printf("Deleting iOS simulator '%s'...\n", deviceID) - // Shutdown the simulator if it's running shutdownCmd := exec.Command(CmdXCrun, CmdSimctl, "shutdown", udid) - _ = shutdownCmd.Run() // Ignore error if already shutdown + _ = shutdownCmd.Run() - // Delete the simulator cmd := exec.Command(CmdXCrun, CmdSimctl, "delete", udid) if err := cmd.Run(); err != nil { fmt.Printf("Error deleting iOS simulator: %v\n", err) @@ -394,10 +386,8 @@ 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(CmdAvdManager, "delete", "avd", "-n", deviceID) if err := cmd.Run(); err != nil { fmt.Printf("Error deleting Android emulator: %v\n", err) @@ -409,10 +399,8 @@ func deleteAndroidEmulator(deviceID string) bool { return true } -// findIOSSimulator returns the UDID and name of a simulator by its name or UDID. func findIOSSimulator(deviceID string) (string, string) { if len(deviceID) == 36 && strings.Count(deviceID, "-") == 4 { - // It's likely a UDID, find its name sims := getIOSSimulators() for _, sim := range sims { if sim.UDID == deviceID { @@ -421,7 +409,6 @@ func findIOSSimulator(deviceID string) (string, string) { } } - // It's a name, find its UDID sims := getIOSSimulators() for _, sim := range sims { if strings.EqualFold(sim.Name, deviceID) { @@ -450,7 +437,8 @@ func getRunningIOSSimulator() (*iOSSimulator, error) { return &iOSSimulator{udid: sim.UDID, name: sim.Name}, nil } } - return nil, fmt.Errorf("no running iOS simulator found") + + return nil, ErrNoRunningIOSSimulator } func doesAndroidAVDExist(avdName string) bool { @@ -475,8 +463,6 @@ func isAndroidEmulatorRunning(avdName string) bool { return udid != "" } -// findRunningAndroidEmulator returns the UDID and name of a running emulator. -// If avdName is empty, it returns the first running emulator it finds. func findRunningAndroidEmulator(avdName string) (string, string) { cmd := exec.Command(CmdAdb, "devices") output, err := cmd.Output() @@ -484,8 +470,8 @@ func findRunningAndroidEmulator(avdName string) (string, string) { 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) > 0 { @@ -494,7 +480,6 @@ func findRunningAndroidEmulator(avdName string) (string, string) { nameOutput, err := nameCmd.Output() if err == nil { actualName := strings.TrimSpace(string(nameOutput)) - // If a name is specified, match it. Otherwise, return the first one. if avdName == "" || actualName == avdName { return emulatorID, actualName } @@ -511,15 +496,6 @@ func getRunningAndroidEmulator() (*androidEmulator, error) { if udid != "" { return &androidEmulator{udid: udid, name: name}, nil } - return nil, fmt.Errorf("no running Android emulator found") -} -func init() { - rootCmd.AddCommand(startCmd) - rootCmd.AddCommand(stopCmd) - rootCmd.AddCommand(shutdownCmd) - rootCmd.AddCommand(restartCmd) - rootCmd.AddCommand(deleteCmd) - rootCmd.AddCommand(lastCmd) - rootCmd.AddCommand(ltsCmd) + 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 fa6ac96..a6d03e0 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -52,7 +52,6 @@ var listCmd = &cobra.Command{ runtimeVal := device.Runtime if strings.Contains(runtimeVal, "com.apple.CoreSimulator.SimRuntime.iOS-") { - // Extract iOS version from runtime string parts := strings.Split(runtimeVal, "-") if len(parts) >= 2 { version := strings.Join(parts[len(parts)-2:], ".") @@ -60,10 +59,10 @@ var listCmd = &cobra.Command{ } } - table.Append([]string{device.Type, device.Name, device.State, udid, runtimeVal}) + _ = table.Append([]string{device.Type, device.Name, device.State, udid, runtimeVal}) } - table.Render() + _ = table.Render() }, } @@ -112,6 +111,7 @@ func getAndroidEmulators() []Device { 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) @@ -125,7 +125,7 @@ func getAndroidEmulators() []Device { adbCmd := exec.Command(CmdAdb, "devices") adbOutput, err := adbCmd.Output() if err != nil { - var devices []Device + devices := make([]Device, 0, len(avdMap)) for avd := range avdMap { devices = append(devices, Device{ Name: avd, @@ -135,12 +135,13 @@ func getAndroidEmulators() []Device { Runtime: "Android", }) } + return devices } runningDevices := make(map[string]string) // map[name]udid - lines := strings.Split(string(adbOutput), "\n") - for _, line := range lines { + 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 { @@ -155,7 +156,7 @@ func getAndroidEmulators() []Device { } } - var devices []Device + devices := make([]Device, 0, len(avdMap)+len(runningDevices)) for name, udid := range runningDevices { devices = append(devices, Device{ Name: name, @@ -164,14 +165,13 @@ func getAndroidEmulators() []Device { Type: TypeAndroidEmulator, Runtime: "Android", }) - // Remove from avdMap so we don't list it twice delete(avdMap, name) } for avd := range avdMap { devices = append(devices, Device{ Name: avd, - UDID: "offline", + UDID: "N/A", State: StateShutdown, Type: TypeAndroidEmulator, Runtime: "Android", @@ -180,7 +180,3 @@ func getAndroidEmulators() []Device { return devices } - -func init() { - rootCmd.AddCommand(listCmd) -} diff --git a/cmd/media.go b/cmd/media.go index 1ee0062..6b0b4eb 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "os/exec" @@ -21,7 +22,7 @@ type capturer interface { GetName() string } -// --- iOS Simulator --- +// --- iOS Simulator ---. type iOSSimulator struct { udid string name string @@ -30,8 +31,9 @@ type iOSSimulator struct { func newIOSSimulator(deviceNameOrUDID string) (*iOSSimulator, error) { udid, name := findIOSSimulator(deviceNameOrUDID) if udid == "" { - return nil, fmt.Errorf("iOS simulator '%s' not found or not running", deviceNameOrUDID) + return nil, ErrIOSSimulatorNotRunning } + return &iOSSimulator{udid: udid, name: name}, nil } @@ -44,6 +46,7 @@ func (s *iOSSimulator) Screenshot(outputFile string) error { return fmt.Errorf("failed to take iOS screenshot: %w", err) } fmt.Printf("Screenshot saved to: %s\n", fullPath) + return nil } @@ -62,21 +65,25 @@ func (s *iOSSimulator) Record(ctx context.Context, outputFile string) error { <-ctx.Done() if err := cmd.Process.Signal(syscall.SIGINT); err != nil { - cmd.Process.Kill() + _ = cmd.Process.Kill() return fmt.Errorf("failed to send interrupt signal to recording process: %w", err) } err := cmd.Wait() - if err != nil { - if _, ok := err.(*exec.ExitError); !ok { - return fmt.Errorf("error during iOS screen recording: %w", err) - } + var exitErr *exec.ExitError + if err != nil && !errors.As(err, &exitErr) { + return fmt.Errorf("error during iOS screen recording: %w", err) } fmt.Printf("\nRecording saved to: %s\n", fullPath) + return nil } +func (s *iOSSimulator) GetName() string { + return s.name +} + // --- Android Emulator --- type androidEmulator struct { @@ -87,8 +94,9 @@ type androidEmulator struct { func newAndroidEmulator(deviceNameOrUDID string) (*androidEmulator, error) { udid, name := findRunningAndroidEmulator(deviceNameOrUDID) if udid == "" { - return nil, fmt.Errorf("android emulator '%s' not found or not running", deviceNameOrUDID) + return nil, ErrAndroidEmulatorNotRunning } + return &androidEmulator{udid: udid, name: name}, nil } @@ -98,6 +106,7 @@ func (e *androidEmulator) runADB(args ...string) error { if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("adb command failed: %w\nOutput: %s", err, string(output)) } + return nil } @@ -106,7 +115,9 @@ func (e *androidEmulator) Screenshot(outputFile string) error { fullPath := ensureExtension(outputFile, ExtPNG) devicePath := "/sdcard/screenshot.png" - defer e.runADB("shell", "rm", devicePath) + 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) @@ -117,6 +128,7 @@ func (e *androidEmulator) Screenshot(outputFile string) error { } fmt.Printf("Screenshot saved to: %s\n", fullPath) + return nil } @@ -125,7 +137,9 @@ func (e *androidEmulator) Record(ctx context.Context, outputFile string) error { fullPath := ensureExtension(outputFile, ExtMP4) devicePath := "/sdcard/recording.mp4" - defer e.runADB("shell", "rm", devicePath) + defer func() { + _ = e.runADB("shell", "rm", devicePath) + }() args := []string{"-s", e.udid, "shell", "screenrecord", devicePath} cmd := exec.Command(CmdAdb, args...) @@ -139,15 +153,14 @@ func (e *androidEmulator) Record(ctx context.Context, outputFile string) error { <-ctx.Done() if err := cmd.Process.Signal(syscall.SIGINT); err != nil { - cmd.Process.Kill() + _ = cmd.Process.Kill() return fmt.Errorf("failed to send interrupt signal to adb process: %w", err) } err := cmd.Wait() - if err != nil { - if _, ok := err.(*exec.ExitError); !ok { - return fmt.Errorf("error during Android screen recording: %w", err) - } + var exitErr *exec.ExitError + if err != nil && !errors.As(err, &exitErr) { + return fmt.Errorf("error during Android screen recording: %w", err) } if err := e.runADB("pull", devicePath, fullPath); err != nil { @@ -155,9 +168,14 @@ func (e *androidEmulator) Record(ctx context.Context, outputFile string) error { } fmt.Printf("\nRecording saved to: %s\n", fullPath) + return nil } +func (e *androidEmulator) GetName() string { + return e.name +} + func getCapturer(deviceID string) (capturer, error) { if deviceID == "" { return getActiveDevice() @@ -171,7 +189,8 @@ func getCapturer(deviceID string) (capturer, error) { if emu, err := newAndroidEmulator(deviceID); err == nil { return emu, nil } - return nil, fmt.Errorf("device '%s' not found or not a running iOS simulator or Android emulator", deviceID) + + return nil, ErrDeviceNotRunning } func getActiveDevice() (capturer, error) { @@ -187,10 +206,10 @@ func getActiveDevice() (capturer, error) { return emu, nil } - return nil, fmt.Errorf("no active iOS simulator or Android emulator found") + return nil, ErrNoActiveDevice } -func handleRecording(c capturer, outputFile string, duration int, convertToGif, copy bool) error { +func handleRecording(c capturer, outputFile string, duration int, convertToGif, shouldCopy bool) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -226,7 +245,7 @@ func handleRecording(c capturer, outputFile string, duration int, convertToGif, } } - if copy { + if shouldCopy { if err := copyFileToClipboard(finalPath); err != nil { fmt.Printf("Warning: could not copy to clipboard: %v\n", err) } else { @@ -279,7 +298,7 @@ var screenshotCmd = &cobra.Command{ return err } - if copy, _ := cmd.Flags().GetBool("copy"); copy { + 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 { @@ -330,27 +349,8 @@ The recording can be stopped by pressing Ctrl+C or by specifying a duration.`, duration, _ := cmd.Flags().GetInt("duration") convertToGif, _ := cmd.Flags().GetBool("gif") - copy, _ := cmd.Flags().GetBool("copy") + shouldCopy, _ := cmd.Flags().GetBool("copy") - return handleRecording(c, outputFile, duration, convertToGif, copy) + return handleRecording(c, outputFile, duration, convertToGif, shouldCopy) }, } - -func (s *iOSSimulator) GetName() string { - return s.name -} - -func (e *androidEmulator) GetName() string { - return e.name -} - -func init() { - rootCmd.AddCommand(screenshotCmd) - rootCmd.AddCommand(recordCmd) - - screenshotCmd.Flags().BoolP("copy", "c", false, "Copy the file path to the clipboard") - - recordCmd.Flags().IntP("duration", "d", 0, "Recording duration in seconds (0 for manual stop via Ctrl+C)") - recordCmd.Flags().BoolP("gif", "g", false, "Convert the recording to a GIF after stopping") - recordCmd.Flags().BoolP("copy", "c", false, "Copy the file path to the clipboard after stopping") -} 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 index 9037f11..2d2f7eb 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -23,6 +23,7 @@ func ensureExtension(filename, ext string) string { if !strings.HasSuffix(strings.ToLower(filename), ext) { return strings.TrimSuffix(filename, filepath.Ext(filename)) + ext } + return filename } @@ -52,6 +53,7 @@ func copyFileToClipboard(filePath string) error { if err := cmd.Run(); err != nil { return fmt.Errorf("failed to copy file to clipboard: %w", err) } + return nil } @@ -66,7 +68,7 @@ func commandExists(cmd string) bool { func convertToGIF(inputFile, outputFile string) error { if !commandExists(CmdFFmpeg) { - return fmt.Errorf("'%s' is not installed. Please install ffmpeg to use the GIF conversion feature", CmdFFmpeg) + return ErrFFmpegNotInstalled } fmt.Println("Converting to GIF...") @@ -75,5 +77,6 @@ func convertToGIF(inputFile, outputFile string) error { return fmt.Errorf("failed to convert to GIF: %w\nOutput: %s", err, string(output)) } fmt.Printf("GIF saved to: %s\n", outputFile) + return nil }