From de330e2d245d308f10f7531d6e3c34fe3faa8ebe Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Mon, 13 Jul 2026 18:06:01 +0700 Subject: [PATCH 1/7] Refactor: extract DeviceManager interface (P1) - Introduces DeviceManager interface for generic platform operations - Migrates iOS logic to IOSManager and Android logic to AndroidManager - Standardizes executeDeviceAction looping over managers - Refactors app.go, media.go, and dashboard.go to use manager lookups --- cmd/android_manager.go | 217 +++++++++++++++++++ cmd/app.go | 62 ++---- cmd/dashboard.go | 14 +- cmd/device.go | 476 +++-------------------------------------- cmd/ios_manager.go | 141 ++++++++++++ cmd/manager.go | 70 ++++++ cmd/media.go | 20 +- 7 files changed, 486 insertions(+), 514 deletions(-) create mode 100644 cmd/android_manager.go create mode 100644 cmd/ios_manager.go create mode 100644 cmd/manager.go diff --git a/cmd/android_manager.go b/cmd/android_manager.go new file mode 100644 index 0000000..f705806 --- /dev/null +++ b/cmd/android_manager.go @@ -0,0 +1,217 @@ +package cmd + +import ( + "fmt" + "strings" + "time" +) + +// androidBootTimeout is the maximum time to wait for an Android emulator to finish booting. +const androidBootTimeout = 120 * time.Second + +// androidBootPollInterval is how often to poll boot status. +const androidBootPollInterval = 3 * time.Second + +type AndroidManager struct{} + +func (m *AndroidManager) Name() string { + return "Android" +} + +func (m *AndroidManager) List() ([]Device, error) { + return GetAndroidEmulators(), nil +} + +func (m *AndroidManager) Start(deviceID string, noWait bool) (bool, error) { + if IsAndroidEmulatorRunning(deviceID) { + PrintInfo(fmt.Sprintf("Android emulator '%s' is already running", deviceID)) + + udid, name := FindRunningAndroidEmulator(deviceID) + device := &Device{ + Name: name, + UDID: udid, + Type: TypeAndroidEmulator, + State: StateBooted, + } + if err := SaveLastStartedDevice(device); err != nil { + PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) + } + + return true, nil + } + + if !DoesAndroidAVDExist(deviceID) { + return false, nil + } + + _, err := packageExecutor.Start(CmdEmulator, "-avd", deviceID) + if err != nil { + return true, fmt.Errorf("failed to start Android emulator '%s': %w", deviceID, err) + } + + if noWait { + device := &Device{ + Name: deviceID, + UDID: "starting", + Type: TypeAndroidEmulator, + State: StateBooted, + } + if err := SaveLastStartedDevice(device); err != nil { + PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) + } + + return true, nil + } + + udid, bootErr := waitForAndroidBoot(deviceID) + if bootErr != nil { + PrintInfo(fmt.Sprintf("Warning: emulator may still be booting: %v", bootErr)) + PrintInfo("Use 'sim last' to check the saved device once it finishes booting.") + udid = "starting" + } + + device := &Device{ + Name: deviceID, + UDID: udid, + Type: TypeAndroidEmulator, + State: StateBooted, + } + if err := SaveLastStartedDevice(device); err != nil { + PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) + } + + return true, nil +} + +// waitForAndroidBoot polls adb until the emulator with the given AVD name has +// fully booted (sys.boot_completed == 1). Returns the emulator serial (UDID). +func waitForAndroidBoot(avdName string) (string, error) { + deadline := time.Now().Add(androidBootTimeout) + + for time.Now().Before(deadline) { + udid, _ := FindRunningAndroidEmulator(avdName) + if udid != "" { + // Check if the system has fully booted. + out, err := packageExecutor.Output(CmdAdb, "-s", udid, "shell", "getprop", "sys.boot_completed") + if err == nil && strings.TrimSpace(string(out)) == "1" { + return udid, nil + } + } + + time.Sleep(androidBootPollInterval) + } + + // Last attempt: maybe it booted right at the deadline. + if udid, _ := FindRunningAndroidEmulator(avdName); udid != "" { + return udid, nil + } + + return "starting", fmt.Errorf("timed out waiting for Android emulator '%s' to boot after %s", avdName, androidBootTimeout) //nolint:err113 +} + +func (m *AndroidManager) Stop(deviceID string) (bool, error) { + udid, _ := FindRunningAndroidEmulator(deviceID) + if udid == "" { + return false, nil + } + + if err := packageExecutor.Run(CmdAdb, "-s", udid, "emu", "kill"); err != nil { + return true, fmt.Errorf("failed to stop Android emulator '%s': %w", deviceID, err) + } + + return true, nil +} + +func (m *AndroidManager) Restart(deviceID string) (bool, error) { + udid, _ := FindRunningAndroidEmulator(deviceID) + if udid == "" { + return false, nil + } + + _, _ = m.Stop(deviceID) + + return m.Start(deviceID, false) +} + +func (m *AndroidManager) Delete(deviceID string) (bool, error) { + if !DoesAndroidAVDExist(deviceID) { + return false, nil + } + + _, _ = m.Stop(deviceID) + + if err := packageExecutor.Run(CmdAvdManager, "delete", "avd", "-n", deviceID); err != nil { + return true, fmt.Errorf("failed to delete Android emulator '%s': %w", deviceID, err) + } + + return true, nil +} + +func (m *AndroidManager) Erase(deviceID string) (bool, error) { + if !DoesAndroidAVDExist(deviceID) { + return false, nil + } + + _, _ = m.Stop(deviceID) + + _, err := packageExecutor.Start(CmdEmulator, "-avd", deviceID, "-wipe-data") + if err != nil { + return true, fmt.Errorf("failed to erase Android emulator '%s': %w", deviceID, err) + } + + return true, nil +} + +func (m *AndroidManager) Clone(sourceDeviceID, newName string) (bool, error) { + if DoesAndroidAVDExist(sourceDeviceID) { + return true, ErrAndroidCloneNotSupported + } + + return false, nil +} + +func (m *AndroidManager) FindRunningDevice(deviceID string) (udid, name string, found bool, err error) { + if deviceID == "" { + u, n := FindRunningAndroidEmulator("") + if u != "" { + return u, n, true, nil + } + + return "", "", false, nil + } + + u, n := FindRunningAndroidEmulator(deviceID) + if u != "" { + return u, n, true, nil + } + + if DoesAndroidAVDExist(deviceID) { + return "", "", true, fmt.Errorf("device %q: %w", deviceID, ErrDeviceNotRunning) + } + + return "", "", false, nil +} + +// DoesAndroidAVDExist checks whether an AVD with the given name is defined. +func DoesAndroidAVDExist(avdName string) bool { + output, err := packageExecutor.Output(CmdEmulator, "-list-avds") + if err != nil { + return false + } + + lines := strings.SplitSeq(strings.TrimSpace(string(output)), "\n") + for line := range lines { + if strings.TrimSpace(line) == avdName { + return true + } + } + + return false +} + +// IsAndroidEmulatorRunning reports whether an emulator with the given AVD name is currently running. +func IsAndroidEmulatorRunning(avdName string) bool { + udid, _ := FindRunningAndroidEmulator(avdName) + + return udid != "" +} diff --git a/cmd/app.go b/cmd/app.go index 863a583..a1a5508 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -60,18 +60,12 @@ If no device is specified, the first booted device is used automatically.`, func findDeviceForAppInstall(deviceID, ext string) (udid, name string, isAndroid bool, err error) { switch ext { case ExtAPK: - if deviceID == "" { - emu, errEmu := getRunningAndroidEmulator() - if errEmu != nil { - return "", "", true, errEmu - } - - return emu.udid, emu.name, true, nil + u, n, isA, err := FindRunningDevice(deviceID) + if err != nil { + return "", "", true, err } - - u, n := FindRunningAndroidEmulator(deviceID) - if u == "" { - return "", "", true, fmt.Errorf("device %q: %w", deviceID, ErrAndroidEmulatorNotRunning) + if !isA { + return "", "", true, ErrAndroidEmulatorNotRunning } return u, n, true, nil @@ -81,21 +75,15 @@ func findDeviceForAppInstall(deviceID, ext string) (udid, name string, isAndroid return "", "", false, ErrIOSMacOnly } - if deviceID == "" { - sim, errSim := getRunningIOSSimulator() - if errSim != nil { - return "", "", false, errSim - } - - return sim.udid, sim.name, false, nil + u, n, isA, err := FindRunningDevice(deviceID) + if err != nil { + return "", "", false, err } - - device := FindIOSSimulatorByID(deviceID) - if device == nil || device.State != StateBooted { - return "", "", false, fmt.Errorf("device %q: %w", deviceID, ErrIOSSimulatorNotRunning) + if isA { + return "", "", false, ErrIOSSimulatorNotRunning } - return device.UDID, device.Name, false, nil + return u, n, false, nil default: return "", "", false, fmt.Errorf("%w: %s", ErrUnsupportedAppFormat, ext) @@ -136,33 +124,7 @@ func InstallApp(deviceID, appPath string) error { } func findDeviceForUninstall(deviceID string) (udid, name string, isAndroid bool, err error) { - if deviceID == "" { - if runtime.GOOS == DarwinOS { - if sim, errSim := getRunningIOSSimulator(); errSim == nil { - return sim.udid, sim.name, false, nil - } - } - - if emu, errEmu := getRunningAndroidEmulator(); errEmu == nil { - return emu.udid, emu.name, true, nil - } - - return "", "", false, ErrNoActiveDevice - } - - if runtime.GOOS == DarwinOS { - device := FindIOSSimulatorByID(deviceID) - if device != nil && device.State == StateBooted { - return device.UDID, device.Name, false, nil - } - } - - u, n := FindRunningAndroidEmulator(deviceID) - if u != "" { - return u, n, true, nil - } - - return "", "", false, fmt.Errorf("device %q: %w", deviceID, ErrDeviceNotRunning) + return FindRunningDevice(deviceID) } func UninstallApp(deviceID, appID string) error { diff --git a/cmd/dashboard.go b/cmd/dashboard.go index d44e699..5b5868c 100644 --- a/cmd/dashboard.go +++ b/cmd/dashboard.go @@ -107,13 +107,17 @@ func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = true m.msg = "Stopping " + row[1] + "..." cmds = append(cmds, doActionCmd(func() error { - if row[0] == "iOS Simulator" || row[0] == TypeIOSSimulator { - _, err := shutdownIOSSimulator(deviceID) - return err + for _, m := range GetManagers() { + found, err := m.Stop(deviceID) + if err != nil { + return err + } + if found { + return nil + } } - _, err := stopAndroidEmulator(deviceID) - return err + return ErrDeviceNotFound }, "Stopped "+row[1])) } case "r": diff --git a/cmd/device.go b/cmd/device.go index 01db158..6da7fa8 100644 --- a/cmd/device.go +++ b/cmd/device.go @@ -3,9 +3,7 @@ package cmd import ( "errors" "fmt" - "runtime" "strings" - "time" "github.com/spf13/cobra" ) @@ -64,47 +62,9 @@ var stopCmd = &cobra.Command{ deviceID = args[0] } - if runtime.GOOS == DarwinOS { - err := RunSpinner(fmt.Sprintf("Stopping device %q...", deviceID), func() error { - found, stopErr := stopIOSSimulator(deviceID) - if stopErr != nil { - return stopErr - } - if !found { - return ErrDeviceNotFound - } - - return nil - }) - if err == nil { - PrintSuccess(fmt.Sprintf("Successfully stopped %s", deviceID)) - return nil - } - if !errors.Is(err, ErrDeviceNotFound) { - return err - } - } - - err := RunSpinner(fmt.Sprintf("Stopping device %q...", deviceID), func() error { - found, stopErr := stopAndroidEmulator(deviceID) - if stopErr != nil { - return stopErr - } - if !found { - return ErrDeviceNotFound - } - - return nil + return executeDeviceAction("Stopping", "stopped", deviceID, func(m DeviceManager, id string) (bool, error) { + return m.Stop(id) }) - if err == nil { - PrintSuccess(fmt.Sprintf("Successfully stopped %s", deviceID)) - return nil - } - if !errors.Is(err, ErrDeviceNotFound) { - return err - } - - return fmt.Errorf("device %q: %w", deviceID, ErrDeviceNotFound) }, } @@ -127,7 +87,9 @@ var shutdownCmd = &cobra.Command{ deviceID = args[0] } - return executeDeviceAction("Shutting down", "shut down", deviceID, shutdownIOSSimulator, stopAndroidEmulator) + return executeDeviceAction("Shutting down", "shut down", deviceID, func(m DeviceManager, id string) (bool, error) { + return m.Stop(id) + }) }, } @@ -150,7 +112,9 @@ var restartCmd = &cobra.Command{ deviceID = args[0] } - return executeDeviceAction("Restarting", "restarted", deviceID, restartIOSSimulator, restartAndroidEmulator) + return executeDeviceAction("Restarting", "restarted", deviceID, func(m DeviceManager, id string) (bool, error) { + return m.Restart(id) + }) }, } @@ -189,7 +153,9 @@ var deleteCmd = &cobra.Command{ } } - return executeDeviceAction("Deleting", "deleted", deviceID, deleteIOSSimulator, deleteAndroidEmulator) + return executeDeviceAction("Deleting", "deleted", deviceID, func(m DeviceManager, id string) (bool, error) { + return m.Delete(id) + }) }, } @@ -224,7 +190,9 @@ This performs a factory reset. The device will be shut down if it is running.`, } } - return executeDeviceAction("Erasing", "erased", deviceID, eraseIOSSimulator, eraseAndroidEmulator) + return executeDeviceAction("Erasing", "erased", deviceID, func(m DeviceManager, id string) (bool, error) { + return m.Erase(id) + }) }, } @@ -238,36 +206,9 @@ var cloneCmd = &cobra.Command{ sourceDevice := args[0] newName := args[1] - if runtime.GOOS == DarwinOS { - err := RunSpinner(fmt.Sprintf("Cloning device %q to %q...", sourceDevice, newName), func() error { - found, cloneErr := cloneIOSSimulator(sourceDevice, newName) - if cloneErr != nil { - return cloneErr - } - if found { - return nil - } - - return ErrDeviceNotFound - }) - - if err == nil { - PrintSuccess(fmt.Sprintf("Successfully cloned %s to %s", sourceDevice, newName)) - return nil - } - if !errors.Is(err, ErrDeviceNotFound) { - return err - } - } else { - return ErrIOSMacOnly - } - - // Check if it's an Android device - if DoesAndroidAVDExist(sourceDevice) { - return ErrAndroidCloneNotSupported - } - - return fmt.Errorf("device %q: %w", sourceDevice, ErrDeviceNotFound) + return executeDeviceAction("Cloning", "cloned", sourceDevice, func(m DeviceManager, id string) (bool, error) { + return m.Clone(id, newName) + }) }, } @@ -322,294 +263,20 @@ func startDevice(deviceID string, noWait bool) error { deviceID = lastDevice.Name } - if runtime.GOOS == DarwinOS { - if found, err := startIOSSimulator(deviceID); found { + for _, m := range GetManagers() { + found, err := m.Start(deviceID, noWait) + if err != nil { return err } - } - - if found, err := startAndroidEmulator(deviceID, noWait); found { - return err - } - - return fmt.Errorf("device %q: %w", deviceID, ErrDeviceNotFound) -} - -// --- iOS Simulator Operations --- - -// startIOSSimulator boots an iOS simulator. -// Returns (true, nil) on success, (true, err) if found but boot failed, (false, nil) if not found. -func startIOSSimulator(deviceID string) (bool, error) { - device := FindIOSSimulatorByID(deviceID) - if device == nil { - return false, nil - } - - if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "boot", device.UDID); err != nil { - return true, fmt.Errorf("failed to boot iOS simulator '%s': %w", deviceID, err) - } - - if err := packageExecutor.Run("open", "-a", "Simulator"); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not open Simulator app: %v", err)) - } - - device.State = StateBooted - if err := SaveLastStartedDevice(device); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) - } - - return true, nil -} - -// stopIOSSimulator shuts down an iOS simulator. -// Returns (true, nil) on success, (true, err) if found but shutdown failed, (false, nil) if not found. -func stopIOSSimulator(deviceID string) (bool, error) { - device := FindIOSSimulatorByID(deviceID) - if device == nil { - return false, nil - } - - if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID); err != nil { - return true, fmt.Errorf("failed to stop iOS simulator '%s': %w", deviceID, err) - } - - return true, nil -} - -// shutdownIOSSimulator is an alias for stopIOSSimulator. -func shutdownIOSSimulator(deviceID string) (bool, error) { - return stopIOSSimulator(deviceID) -} - -// restartIOSSimulator shuts down then boots an iOS simulator. -// Returns (true, nil) on success, (true, err) if found but restart failed, (false, nil) if not found. -func restartIOSSimulator(deviceID string) (bool, error) { - device := FindIOSSimulatorByID(deviceID) - if device == nil { - return false, nil - } - - _ = packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID) // Ignore error if already shut down. - - if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "boot", device.UDID); err != nil { - return true, fmt.Errorf("failed to boot iOS simulator '%s' during restart: %w", deviceID, err) - } - - if err := packageExecutor.Run("open", "-a", "Simulator"); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not open Simulator app: %v", err)) - } - - device.State = StateBooted - if err := SaveLastStartedDevice(device); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) - } - - return true, nil -} - -// deleteIOSSimulator removes an iOS simulator permanently. -// Returns (true, nil) on success, (true, err) if found but delete failed, (false, nil) if not found. -func deleteIOSSimulator(deviceID string) (bool, error) { - device := FindIOSSimulatorByID(deviceID) - if device == nil { - return false, nil - } - - _ = packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID) - - if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "delete", device.UDID); err != nil { - return true, fmt.Errorf("failed to delete iOS simulator '%s': %w", deviceID, err) - } - - return true, nil -} - -// eraseIOSSimulator factory resets an iOS simulator. -func eraseIOSSimulator(deviceID string) (bool, error) { - device := FindIOSSimulatorByID(deviceID) - if device == nil { - return false, nil - } - - _ = packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID) - - if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "erase", device.UDID); err != nil { - return true, fmt.Errorf("failed to erase iOS simulator '%s': %w", deviceID, err) - } - - return true, nil -} - -// cloneIOSSimulator clones an iOS simulator. -func cloneIOSSimulator(sourceDeviceID, newName string) (bool, error) { - device := FindIOSSimulatorByID(sourceDeviceID) - if device == nil { - return false, nil - } - - if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "clone", device.UDID, newName); err != nil { - return true, fmt.Errorf("failed to clone iOS simulator '%s': %w", sourceDeviceID, err) - } - - return true, nil -} - -// --- Android Emulator Operations --- - -// androidBootTimeout is the maximum time to wait for an Android emulator to finish booting. -const androidBootTimeout = 120 * time.Second - -// androidBootPollInterval is how often to poll boot status. -const androidBootPollInterval = 3 * time.Second - -// startAndroidEmulator starts an Android emulator. -// NoWait skips boot polling and returns immediately after launching the process. -// Returns (true, nil) on success, (true, err) if found but start failed, (false, nil) if not found. -func startAndroidEmulator(deviceID string, noWait bool) (bool, error) { - if IsAndroidEmulatorRunning(deviceID) { - PrintInfo(fmt.Sprintf("Android emulator '%s' is already running", deviceID)) - - udid, name := FindRunningAndroidEmulator(deviceID) - device := &Device{ - Name: name, - UDID: udid, - Type: TypeAndroidEmulator, - State: StateBooted, - } - if err := SaveLastStartedDevice(device); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) - } - - return true, nil - } - - if !DoesAndroidAVDExist(deviceID) { - return false, nil - } - - _, err := packageExecutor.Start(CmdEmulator, "-avd", deviceID) - if err != nil { - return true, fmt.Errorf("failed to start Android emulator '%s': %w", deviceID, err) - } - - if noWait { - // Fire-and-forget: save a placeholder UDID and return immediately. - device := &Device{ - Name: deviceID, - UDID: "starting", - Type: TypeAndroidEmulator, - State: StateBooted, - } - if err := SaveLastStartedDevice(device); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) - } - - return true, nil - } - - // Wait for the emulator to fully boot and resolve its real UDID. - udid, bootErr := waitForAndroidBoot(deviceID) - if bootErr != nil { - PrintInfo( // Non-fatal: emulator may still be booting. Save placeholder and warn. - fmt.Sprintf("Warning: emulator may still be booting: %v", bootErr)) - PrintInfo("Use 'sim last' to check the saved device once it finishes booting.") - udid = "starting" - } - - device := &Device{ - Name: deviceID, - UDID: udid, - Type: TypeAndroidEmulator, - State: StateBooted, - } - if err := SaveLastStartedDevice(device); err != nil { - PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) - } - - return true, nil -} - -// waitForAndroidBoot polls adb until the emulator with the given AVD name has -// fully booted (sys.boot_completed == 1). Returns the emulator serial (UDID). -func waitForAndroidBoot(avdName string) (string, error) { - deadline := time.Now().Add(androidBootTimeout) - - for time.Now().Before(deadline) { - udid, _ := FindRunningAndroidEmulator(avdName) - if udid != "" { - // Check if the system has fully booted. - out, err := packageExecutor.Output(CmdAdb, "-s", udid, "shell", "getprop", "sys.boot_completed") - if err == nil && strings.TrimSpace(string(out)) == "1" { - return udid, nil - } + if found { + return nil } - - time.Sleep(androidBootPollInterval) - } - - // Last attempt: maybe it booted right at the deadline. - if udid, _ := FindRunningAndroidEmulator(avdName); udid != "" { - return udid, nil - } - - return "starting", fmt.Errorf("timed out waiting for Android emulator '%s' to boot after %s", avdName, androidBootTimeout) //nolint:err113 -} - -// stopAndroidEmulator kills a running Android emulator. -// Returns (true, nil) on success, (true, err) if found but kill failed, (false, nil) if not found. -func stopAndroidEmulator(deviceID string) (bool, error) { - udid, _ := FindRunningAndroidEmulator(deviceID) - if udid == "" { - return false, nil - } - - if err := packageExecutor.Run(CmdAdb, "-s", udid, "emu", "kill"); err != nil { - return true, fmt.Errorf("failed to stop Android emulator '%s': %w", deviceID, err) - } - - return true, nil -} - -// restartAndroidEmulator stops then starts an Android emulator. -// Returns (true, nil) on success, (true, err) if found but restart failed, (false, nil) if not found. -func restartAndroidEmulator(deviceID string) (bool, error) { - _, _ = stopAndroidEmulator(deviceID) - - return startAndroidEmulator(deviceID, false) -} - -// deleteAndroidEmulator removes an Android AVD permanently. -// Returns (true, nil) on success, (true, err) if found but delete failed, (false, nil) if not found. -func deleteAndroidEmulator(deviceID string) (bool, error) { - if !DoesAndroidAVDExist(deviceID) { - return false, nil } - _, _ = stopAndroidEmulator(deviceID) - - if err := packageExecutor.Run(CmdAvdManager, "delete", "avd", "-n", deviceID); err != nil { - return true, fmt.Errorf("failed to delete Android emulator '%s': %w", deviceID, err) - } - - return true, nil + return fmt.Errorf("device %q: %w", deviceID, ErrDeviceNotFound) } -// eraseAndroidEmulator factory resets an Android emulator. -// Returns (true, nil) on success, (true, err) if found but erase failed, (false, nil) if not found. -func eraseAndroidEmulator(deviceID string) (bool, error) { - if !DoesAndroidAVDExist(deviceID) { - return false, nil - } - - _, _ = stopAndroidEmulator(deviceID) - - _, err := packageExecutor.Start(CmdEmulator, "-avd", deviceID, "-wipe-data") - if err != nil { - return true, fmt.Errorf("failed to erase Android emulator '%s': %w", deviceID, err) - } - - return true, nil -} +// --- Old functions removed --- // --- Device Lookup Helpers --- @@ -667,98 +334,11 @@ func FindRunningAndroidEmulator(avdName string) (string, string) { return "", "" } -// FindRunningDevice finds a running device by ID, or the active device if ID is empty. -func FindRunningDevice(deviceID string) (udid, name string, isAndroid bool, err error) { - if deviceID == "" { - if runtime.GOOS == DarwinOS { - if sim, errSim := getRunningIOSSimulator(); errSim == nil { - return sim.udid, sim.name, false, nil - } - } - - if emu, errEmu := getRunningAndroidEmulator(); errEmu == nil { - return emu.udid, emu.name, true, nil - } - - return "", "", false, ErrNoActiveDevice - } - - if runtime.GOOS == DarwinOS { - device := FindIOSSimulatorByID(deviceID) - if device != nil && device.State == StateBooted { - return device.UDID, device.Name, false, nil - } - } - - u, n := FindRunningAndroidEmulator(deviceID) - if u != "" { - return u, n, true, nil - } - - return "", "", false, fmt.Errorf("device %q: %w", deviceID, ErrDeviceNotRunning) -} - -// DoesAndroidAVDExist checks whether an AVD with the given name is defined. -func DoesAndroidAVDExist(avdName string) bool { - output, err := packageExecutor.Output(CmdEmulator, "-list-avds") - if err != nil { - return false - } - - lines := strings.SplitSeq(strings.TrimSpace(string(output)), "\n") - for line := range lines { - if strings.TrimSpace(line) == avdName { - return true - } - } - - return false -} - -// IsAndroidEmulatorRunning reports whether an emulator with the given AVD name is currently running. -func IsAndroidEmulatorRunning(avdName string) bool { - udid, _ := FindRunningAndroidEmulator(avdName) - - return udid != "" -} - -// getRunningIOSSimulator returns the first booted iOS simulator, for auto-selection. -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 -} - -// getRunningAndroidEmulator returns any currently running Android emulator, for auto-selection. -func getRunningAndroidEmulator() (*androidEmulator, error) { - udid, name := FindRunningAndroidEmulator("") - if udid != "" { - return &androidEmulator{udid: udid, name: name}, nil - } - - return nil, ErrNoRunningAndroidEmulator -} - -// executeDeviceAction executes an iOS or Android device action with a spinner. -func executeDeviceAction(actionIng, actionEd, deviceID string, iosFunc, androidFunc func(string) (bool, error)) error { +// executeDeviceAction executes a device action with a spinner across all managers. +func executeDeviceAction(actionIng, actionEd, deviceID string, action func(m DeviceManager, id string) (bool, error)) error { err := RunSpinner(fmt.Sprintf("%s device %q...", actionIng, deviceID), func() error { - if runtime.GOOS == DarwinOS && iosFunc != nil { - found, err := iosFunc(deviceID) - if err != nil { - return err - } - if found { - return nil - } - } - - if androidFunc != nil { - found, err := androidFunc(deviceID) + for _, m := range GetManagers() { + found, err := action(m, deviceID) if err != nil { return err } diff --git a/cmd/ios_manager.go b/cmd/ios_manager.go new file mode 100644 index 0000000..a0dc7d7 --- /dev/null +++ b/cmd/ios_manager.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "fmt" +) + +type IOSManager struct{} + +func (m *IOSManager) Name() string { + return "iOS" +} + +func (m *IOSManager) List() ([]Device, error) { + return GetIOSSimulators(), nil +} + +func (m *IOSManager) Start(deviceID string, noWait bool) (bool, error) { + device := FindIOSSimulatorByID(deviceID) + if device == nil { + return false, nil + } + + if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "boot", device.UDID); err != nil { + return true, fmt.Errorf("failed to boot iOS simulator '%s': %w", deviceID, err) + } + + if err := packageExecutor.Run("open", "-a", "Simulator"); err != nil { + PrintInfo(fmt.Sprintf("Warning: could not open Simulator app: %v", err)) + } + + device.State = StateBooted + if err := SaveLastStartedDevice(device); err != nil { + PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) + } + + return true, nil +} + +func (m *IOSManager) Stop(deviceID string) (bool, error) { + device := FindIOSSimulatorByID(deviceID) + if device == nil { + return false, nil + } + + if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID); err != nil { + return true, fmt.Errorf("failed to stop iOS simulator '%s': %w", deviceID, err) + } + + return true, nil +} + +func (m *IOSManager) Restart(deviceID string) (bool, error) { + device := FindIOSSimulatorByID(deviceID) + if device == nil { + return false, nil + } + + _ = packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID) // Ignore error if already shut down. + + if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "boot", device.UDID); err != nil { + return true, fmt.Errorf("failed to boot iOS simulator '%s' during restart: %w", deviceID, err) + } + + if err := packageExecutor.Run("open", "-a", "Simulator"); err != nil { + PrintInfo(fmt.Sprintf("Warning: could not open Simulator app: %v", err)) + } + + device.State = StateBooted + if err := SaveLastStartedDevice(device); err != nil { + PrintInfo(fmt.Sprintf("Warning: could not save last started device: %v", err)) + } + + return true, nil +} + +func (m *IOSManager) Delete(deviceID string) (bool, error) { + device := FindIOSSimulatorByID(deviceID) + if device == nil { + return false, nil + } + + _ = packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID) + + if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "delete", device.UDID); err != nil { + return true, fmt.Errorf("failed to delete iOS simulator '%s': %w", deviceID, err) + } + + return true, nil +} + +func (m *IOSManager) Erase(deviceID string) (bool, error) { + device := FindIOSSimulatorByID(deviceID) + if device == nil { + return false, nil + } + + _ = packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID) + + if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "erase", device.UDID); err != nil { + return true, fmt.Errorf("failed to erase iOS simulator '%s': %w", deviceID, err) + } + + return true, nil +} + +func (m *IOSManager) Clone(sourceDeviceID, newName string) (bool, error) { + device := FindIOSSimulatorByID(sourceDeviceID) + if device == nil { + return false, nil + } + + if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "clone", device.UDID, newName); err != nil { + return true, fmt.Errorf("failed to clone iOS simulator '%s': %w", sourceDeviceID, err) + } + + return true, nil +} + +func (m *IOSManager) FindRunningDevice(deviceID string) (udid, name string, found bool, err error) { + if deviceID == "" { + sims := GetIOSSimulators() + for _, sim := range sims { + if sim.State == StateBooted { + return sim.UDID, sim.Name, true, nil + } + } + + return "", "", false, nil // ErrNoRunningIOSSimulator handled in manager.go + } + + device := FindIOSSimulatorByID(deviceID) + if device != nil && device.State == StateBooted { + return device.UDID, device.Name, true, nil + } + + if device != nil && device.State != StateBooted { + return "", "", true, fmt.Errorf("device %q: %w", deviceID, ErrDeviceNotRunning) + } + + return "", "", false, nil +} diff --git a/cmd/manager.go b/cmd/manager.go new file mode 100644 index 0000000..b2d3b31 --- /dev/null +++ b/cmd/manager.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "fmt" + "runtime" +) + +// DeviceManager defines the interface for platform-specific device operations. +type DeviceManager interface { + // Name returns the display name of the platform (e.g. "iOS", "Android") + Name() string + + // List returns all devices for this platform. + List() ([]Device, error) + + // Start boots the device. noWait applies to Android to skip boot polling. + Start(deviceID string, noWait bool) (bool, error) + + // Stop shuts down the device. + Stop(deviceID string) (bool, error) + + // Restart stops and starts the device. + Restart(deviceID string) (bool, error) + + // Delete permanently removes the device. + Delete(deviceID string) (bool, error) + + // Erase factory resets the device. + Erase(deviceID string) (bool, error) + + // Clone duplicates the device (iOS only). + Clone(sourceDeviceID, newName string) (bool, error) + + // FindRunningDevice finds a running device by its ID. + // If deviceID is empty, it returns the first/active running device. + FindRunningDevice(deviceID string) (udid, name string, found bool, err error) +} + +// activeManagers holds the registered device managers for the current OS. +var activeManagers []DeviceManager + +func init() { + if runtime.GOOS == DarwinOS { + activeManagers = append(activeManagers, &IOSManager{}) + } + activeManagers = append(activeManagers, &AndroidManager{}) +} + +// GetManagers returns the active device managers. +func GetManagers() []DeviceManager { + return activeManagers +} + +// FindRunningDevice unified search across all active platform managers. +func FindRunningDevice(deviceID string) (udid, name string, isAndroid bool, err error) { + for _, m := range activeManagers { + u, n, found, err := m.FindRunningDevice(deviceID) + if err != nil { + return "", "", false, err + } + if found { + return u, n, m.Name() == "Android", nil + } + } + if deviceID == "" { + return "", "", false, ErrNoActiveDevice + } + + return "", "", false, fmt.Errorf("device %q: %w", deviceID, ErrDeviceNotRunning) +} diff --git a/cmd/media.go b/cmd/media.go index 3dfca03..dff6427 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -201,21 +201,19 @@ func getCapturer(deviceID string) (capturer, error) { } func getActiveDevice() (capturer, error) { - if runtime.GOOS == DarwinOS { - if sim, err := getRunningIOSSimulator(); err == nil { - PrintInfo(fmt.Sprintf("Active device found: iOS Simulator '%s'", sim.name)) - - return sim, nil - } + u, n, isAndroid, err := FindRunningDevice("") + if err != nil { + return nil, err } - if emu, err := getRunningAndroidEmulator(); err == nil { - PrintInfo(fmt.Sprintf("Active device found: Android Emulator '%s'", emu.name)) - - return emu, nil + if isAndroid { + PrintInfo(fmt.Sprintf("Active device found: Android Emulator '%s'", n)) + return &androidEmulator{udid: u, name: n}, nil } - return nil, ErrNoActiveDevice + PrintInfo(fmt.Sprintf("Active device found: iOS Simulator '%s'", n)) + + return &iOSSimulator{udid: u, name: n}, nil } // parseDeviceAndFileArgs resolves the optional [device] [file] positional arguments. From d2340ce1479ccebb1c5147b2202882199d5078ce Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Mon, 13 Jul 2026 18:12:08 +0700 Subject: [PATCH 2/7] test: add performance benchmarks for device listing --- cmd/list_benchmark_test.go | 95 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 cmd/list_benchmark_test.go diff --git a/cmd/list_benchmark_test.go b/cmd/list_benchmark_test.go new file mode 100644 index 0000000..0f138e7 --- /dev/null +++ b/cmd/list_benchmark_test.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "os/exec" + "testing" +) + +type mockBenchmarkExecutor struct { + iosOutput []byte +} + +func (m *mockBenchmarkExecutor) Output(name string, args ...string) ([]byte, error) { + if name == CmdXCrun { + return m.iosOutput, nil + } + if name == CmdEmulator { + // mock avds list + return []byte("Pixel_8_API_34\nPixel_7_API_34\nNexus_5X_API_29\n"), nil + } + if name == CmdAdb { + if len(args) > 0 && args[0] == "devices" { + return []byte("List of devices attached\nemulator-5554 device\nemulator-5556 offline\n"), nil + } + if len(args) > 3 && args[3] == "name" { + if args[2] == "emulator-5554" { + return []byte("Pixel_8_API_34\nOK\n"), nil + } + return []byte("Unknown_AVD\nOK\n"), nil + } + } + return nil, nil +} + +func (m *mockBenchmarkExecutor) Run(name string, args ...string) error { return nil } +func (m *mockBenchmarkExecutor) Start(name string, args ...string) (*exec.Cmd, error) { return nil, nil } + +var benchmarkIOSOutput = []byte(`{ + "devices" : { + "com.apple.CoreSimulator.SimRuntime.iOS-17-0" : [ + { + "udid" : "1234-5678-9012-3456", + "isAvailable" : true, + "deviceTypeIdentifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-15", + "state" : "Booted", + "name" : "iPhone 15" + }, + { + "udid" : "2345-6789-0123-4567", + "isAvailable" : true, + "deviceTypeIdentifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro", + "state" : "Shutdown", + "name" : "iPhone 15 Pro" + } + ], + "com.apple.CoreSimulator.SimRuntime.iOS-16-4" : [ + { + "udid" : "3456-7890-1234-5678", + "isAvailable" : true, + "deviceTypeIdentifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-14", + "state" : "Shutdown", + "name" : "iPhone 14" + } + ] + } +}`) + +func BenchmarkGetIOSSimulators(b *testing.B) { + mockExec := &mockBenchmarkExecutor{ + iosOutput: benchmarkIOSOutput, + } + SetExecutor(mockExec) + defer SetExecutor(&OSCommandExecutor{}) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = GetIOSSimulators() + } +} + +func BenchmarkGetAndroidEmulators(b *testing.B) { + mockExec := &mockBenchmarkExecutor{} + SetExecutor(mockExec) + defer SetExecutor(&OSCommandExecutor{}) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = GetAndroidEmulators() + } +} + +func BenchmarkFormatRuntime(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = FormatRuntime("com.apple.CoreSimulator.SimRuntime.iOS-17-0") + } +} From 772c35b5e2a879085c3824a7948a9a3caccc4e47 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Thu, 16 Jul 2026 17:29:11 +0700 Subject: [PATCH 3/7] fix: address P0 critical vulnerabilities and refine UX - Fix shell injection in create.go and logs.go - Fix config directory creation to not fallback to /tmp - Fix copy heuristic for device detection - Fix silent error swallowing across device teardowns and generations - Merge shutdown command into stop command as alias - Extract hardcoded strings into constants - Update CI configuration to run all tests - Ignore coverage output files --- .github/workflows/ci.yml | 2 +- .gitignore | 3 ++- cmd/android_manager.go | 14 +++++++++---- cmd/completion.go | 17 ++++++++++++---- cmd/config.go | 41 ++++++++++++++++++++++++++++----------- cmd/config_cmd.go | 6 +++++- cmd/constants.go | 2 ++ cmd/copy.go | 27 ++++++++++++++++++-------- cmd/create.go | 32 +++++++++++++++++++++--------- cmd/device.go | 31 +++-------------------------- cmd/ios_manager.go | 14 +++++++++---- cmd/log_viewer.go | 8 ++++++-- cmd/logs.go | 17 +++++++--------- cmd/manager.go | 2 +- cmd/root.go | 1 - cmd/ui.go | 10 +++++----- tests/config_test.go | 16 ++++++++++++--- tests/integration_test.go | 24 ++++++++++++++++++----- 18 files changed, 169 insertions(+), 98 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92afef3..19a227c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: args: --timeout=5m - name: Run tests with race detection - run: go test -race ./tests/ + run: go test -race ./... - name: Build application run: make build diff --git a/.gitignore b/.gitignore index b20391b..55ab1e0 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ recording_*.mp4 *.png # Temporary files -/tmp/ \ No newline at end of file +/tmp/coverage.out +coverage.html diff --git a/cmd/android_manager.go b/cmd/android_manager.go index f705806..7061298 100644 --- a/cmd/android_manager.go +++ b/cmd/android_manager.go @@ -15,7 +15,7 @@ const androidBootPollInterval = 3 * time.Second type AndroidManager struct{} func (m *AndroidManager) Name() string { - return "Android" + return NameAndroid } func (m *AndroidManager) List() ([]Device, error) { @@ -128,7 +128,9 @@ func (m *AndroidManager) Restart(deviceID string) (bool, error) { return false, nil } - _, _ = m.Stop(deviceID) + if _, err := m.Stop(deviceID); err != nil { + PrintInfo(fmt.Sprintf("Warning: failed to stop device before restart: %v", err)) + } return m.Start(deviceID, false) } @@ -138,7 +140,9 @@ func (m *AndroidManager) Delete(deviceID string) (bool, error) { return false, nil } - _, _ = m.Stop(deviceID) + if _, err := m.Stop(deviceID); err != nil { + PrintInfo(fmt.Sprintf("Warning: failed to stop device before delete: %v", err)) + } if err := packageExecutor.Run(CmdAvdManager, "delete", "avd", "-n", deviceID); err != nil { return true, fmt.Errorf("failed to delete Android emulator '%s': %w", deviceID, err) @@ -152,7 +156,9 @@ func (m *AndroidManager) Erase(deviceID string) (bool, error) { return false, nil } - _, _ = m.Stop(deviceID) + if _, err := m.Stop(deviceID); err != nil { + PrintInfo(fmt.Sprintf("Warning: failed to stop device before erase: %v", err)) + } _, err := packageExecutor.Start(CmdEmulator, "-avd", deviceID, "-wipe-data") if err != nil { diff --git a/cmd/completion.go b/cmd/completion.go index f8b651c..94fdaad 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "os" "runtime" @@ -55,13 +56,21 @@ PowerShell: Run: func(cmd *cobra.Command, args []string) { switch args[0] { case "bash": - _ = cmd.Root().GenBashCompletion(os.Stdout) + if err := cmd.Root().GenBashCompletion(os.Stdout); err != nil { + PrintError(fmt.Sprintf("Failed to generate bash completion: %v", err)) + } case "zsh": - _ = cmd.Root().GenZshCompletion(os.Stdout) + if err := cmd.Root().GenZshCompletion(os.Stdout); err != nil { + PrintError(fmt.Sprintf("Failed to generate zsh completion: %v", err)) + } case "fish": - _ = cmd.Root().GenFishCompletion(os.Stdout, true) + if err := cmd.Root().GenFishCompletion(os.Stdout, true); err != nil { + PrintError(fmt.Sprintf("Failed to generate fish completion: %v", err)) + } case "powershell": - _ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + if err := cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout); err != nil { + PrintError(fmt.Sprintf("Failed to generate powershell completion: %v", err)) + } } }, } diff --git a/cmd/config.go b/cmd/config.go index 3a648b6..2eccb1a 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "fmt" "os" "path/filepath" ) @@ -17,25 +18,36 @@ type Config struct { } // GetConfigDir returns the path to the sim-cli configuration directory. -func GetConfigDir() string { +func GetConfigDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { - return os.TempDir() + return "", fmt.Errorf("could not determine user home directory: %w", err) } - return filepath.Join(homeDir, ".sim-cli") + return filepath.Join(homeDir, ".sim-cli"), nil } // GetConfigPath returns the full path to the configuration file. -func GetConfigPath() string { - return filepath.Join(GetConfigDir(), "config.json") +func GetConfigPath() (string, error) { + dir, err := GetConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil } -// LoadConfig reads and returns the current application configuration. func LoadConfig() (*Config, error) { - configPath := GetConfigPath() + configPath, err := GetConfigPath() + if err != nil { + return &Config{}, err + } + + configDir, err := GetConfigDir() + if err != nil { + return &Config{}, err + } - if err := os.MkdirAll(GetConfigDir(), 0o755); err != nil { + if err := os.MkdirAll(configDir, 0o755); err != nil { return &Config{}, err } @@ -56,11 +68,18 @@ func LoadConfig() (*Config, error) { return &config, nil } -// SaveConfig writes the given configuration to disk with secure permissions. func SaveConfig(config *Config) error { - configPath := GetConfigPath() + configPath, err := GetConfigPath() + if err != nil { + return err + } + + configDir, err := GetConfigDir() + if err != nil { + return err + } - if err := os.MkdirAll(GetConfigDir(), 0o755); err != nil { + if err := os.MkdirAll(configDir, 0o755); err != nil { return err } diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 31a5aaa..b620c7b 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -110,7 +110,11 @@ var configPathCmd = &cobra.Command{ Use: "path", Short: "Print the path to the configuration file", RunE: func(cmd *cobra.Command, args []string) error { - PrintInfo(GetConfigPath()) + path, err := GetConfigPath() + if err != nil { + return err + } + PrintInfo(path) return nil }, diff --git a/cmd/constants.go b/cmd/constants.go index 051ca01..6fbccdc 100644 --- a/cmd/constants.go +++ b/cmd/constants.go @@ -8,6 +8,8 @@ const ( TypeAndroidEmulator = "Android Emulator" PlatformIOS = "ios" PlatformAndroid = "android" + NameIOS = "iOS" + NameAndroid = "Android" ExtPNG = ".png" ExtMP4 = ".mp4" ExtGIF = ".gif" diff --git a/cmd/copy.go b/cmd/copy.go index 8d793a6..e38503e 100644 --- a/cmd/copy.go +++ b/cmd/copy.go @@ -1,9 +1,11 @@ package cmd import ( + "errors" "fmt" "path/filepath" "runtime" + "strings" "github.com/spf13/cobra" ) @@ -83,16 +85,25 @@ var copyFromCmd = &cobra.Command{ remotePath = args[0] localPath = "." case 2: - // If it matches a device, arg0 is device, arg1 is remote. Else arg0 is remote, arg1 is local. - // Try to find device with arg0 - _, _, _, err := FindRunningDevice(args[0]) - if err == nil { - deviceID = args[0] - remotePath = args[1] - localPath = "." - } else { + if strings.Contains(args[0], "/") || strings.Contains(args[0], "\\") { + // arg0 looks like a path (remote or local), so arg0=remote, arg1=local remotePath = args[0] localPath = args[1] + } else { + // arg0 might be a device + _, _, _, err := FindRunningDevice(args[0]) + if err == nil { + deviceID = args[0] + remotePath = args[1] + localPath = "." + } else if !errors.Is(err, ErrDeviceNotRunning) && !errors.Is(err, ErrDeviceNotFound) && !errors.Is(err, ErrNoActiveDevice) { + // Transient error during device lookup + return err + } else { + // Fallback to path + remotePath = args[0] + localPath = args[1] + } } case 3: deviceID = args[0] diff --git a/cmd/create.go b/cmd/create.go index fc47c6b..263f7bf 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -189,8 +189,8 @@ func CreateAndroidDevice(name, deviceType, runtimeID string) error { } // avdmanager prompts for custom hardware profile. We pipe "no\n" to it. - commandStr := fmt.Sprintf("echo 'no' | avdmanager %s", strings.Join(args, " ")) - cmd := exec.Command("sh", "-c", commandStr) + cmd := exec.Command("avdmanager", args...) + cmd.Stdin = strings.NewReader("no\n") if err := cmd.Run(); err != nil { return fmt.Errorf("failed to create Android emulator: %w", err) @@ -289,7 +289,7 @@ func runCreateWizard() error { err := huh.NewSelect[string](). Title("Select Platform"). Options( - huh.NewOption("iOS Simulator", "ios"), + huh.NewOption("iOS Simulator", PlatformIOS), huh.NewOption("Android Emulator", "android"), ). Value(&platform). @@ -302,12 +302,26 @@ func runCreateWizard() error { var deviceTypes []string err = RunSpinner("Fetching available runtimes and types...", func() error { - if platform == "ios" { - runtimes, _ = fetchIOSRuntimes() - deviceTypes, _ = fetchIOSDeviceTypes() + if platform == PlatformIOS { + var err1, err2 error + runtimes, err1 = fetchIOSRuntimes() + if err1 != nil { + return err1 + } + deviceTypes, err2 = fetchIOSDeviceTypes() + if err2 != nil { + return err2 + } } else { - runtimes, _ = fetchAndroidSystemImages() - deviceTypes, _ = fetchAndroidDeviceTypes() + var err1, err2 error + runtimes, err1 = fetchAndroidSystemImages() + if err1 != nil { + return err1 + } + deviceTypes, err2 = fetchAndroidDeviceTypes() + if err2 != nil { + return err2 + } } return nil @@ -365,7 +379,7 @@ func runCreateWizard() error { return err } - if platform == "ios" { + if platform == PlatformIOS { createIOS = true } else { createAndroid = true diff --git a/cmd/device.go b/cmd/device.go index 6da7fa8..5b52e24 100644 --- a/cmd/device.go +++ b/cmd/device.go @@ -45,9 +45,9 @@ Use 'lts' to start the last started device.`, var stopCmd = &cobra.Command{ Use: "stop [device-name-or-udid]", - Aliases: []string{"st"}, - Short: "Stop a running iOS simulator or Android emulator", - Long: `Stop a specific running iOS simulator or Android emulator by name or UDID.`, + Aliases: []string{"st", "shutdown", "sd"}, + Short: "Stop/Shutdown a running iOS simulator or Android emulator", + Long: `Stop or shutdown a specific running iOS simulator or Android emulator by name or UDID.`, ValidArgsFunction: validDeviceArgs, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -68,31 +68,6 @@ var stopCmd = &cobra.Command{ }, } -var shutdownCmd = &cobra.Command{ - Use: "shutdown [device-name-or-udid]", - Aliases: []string{"sd"}, - Short: "Shutdown an iOS simulator or Android emulator", - Long: `Shutdown a specific iOS simulator or Android emulator by name or UDID.`, - ValidArgsFunction: validDeviceArgs, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var deviceID string - if len(args) == 0 { - selected, err := PromptDeviceSelector("booted") - if err != nil { - return err - } - deviceID = selected - } else { - deviceID = args[0] - } - - return executeDeviceAction("Shutting down", "shut down", deviceID, func(m DeviceManager, id string) (bool, error) { - return m.Stop(id) - }) - }, -} - var restartCmd = &cobra.Command{ Use: "restart [device-name-or-udid]", Aliases: []string{"r"}, diff --git a/cmd/ios_manager.go b/cmd/ios_manager.go index a0dc7d7..f9030db 100644 --- a/cmd/ios_manager.go +++ b/cmd/ios_manager.go @@ -7,7 +7,7 @@ import ( type IOSManager struct{} func (m *IOSManager) Name() string { - return "iOS" + return NameIOS } func (m *IOSManager) List() ([]Device, error) { @@ -55,7 +55,9 @@ func (m *IOSManager) Restart(deviceID string) (bool, error) { return false, nil } - _ = packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID) // Ignore error if already shut down. + if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID); err != nil { + PrintInfo(fmt.Sprintf("Warning: failed to shut down device before restart: %v", err)) + } if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "boot", device.UDID); err != nil { return true, fmt.Errorf("failed to boot iOS simulator '%s' during restart: %w", deviceID, err) @@ -79,7 +81,9 @@ func (m *IOSManager) Delete(deviceID string) (bool, error) { return false, nil } - _ = packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID) + if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID); err != nil { + PrintInfo(fmt.Sprintf("Warning: failed to shut down device before delete: %v", err)) + } if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "delete", device.UDID); err != nil { return true, fmt.Errorf("failed to delete iOS simulator '%s': %w", deviceID, err) @@ -94,7 +98,9 @@ func (m *IOSManager) Erase(deviceID string) (bool, error) { return false, nil } - _ = packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID) + if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "shutdown", device.UDID); err != nil { + PrintInfo(fmt.Sprintf("Warning: failed to shut down device before erase: %v", err)) + } if err := packageExecutor.Run(CmdXCrun, CmdSimctl, "erase", device.UDID); err != nil { return true, fmt.Errorf("failed to erase iOS simulator '%s': %w", deviceID, err) diff --git a/cmd/log_viewer.go b/cmd/log_viewer.go index c2f5f1b..a1403b4 100644 --- a/cmd/log_viewer.go +++ b/cmd/log_viewer.go @@ -20,7 +20,7 @@ type logViewerModel struct { paused bool } -func runLogViewer(stdout io.ReadCloser) error { +func runLogViewer(stdout io.ReadCloser, filter string) error { m := logViewerModel{ lines: []string{}, } @@ -29,7 +29,11 @@ func runLogViewer(stdout io.ReadCloser) error { go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { - p.Send(logMsg(scanner.Text())) + line := scanner.Text() + if filter != "" && !strings.Contains(line, filter) { + continue + } + p.Send(logMsg(line)) } }() diff --git a/cmd/logs.go b/cmd/logs.go index 57135d4..f506bb3 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -85,12 +85,10 @@ func streamLogs(deviceID, level, filter, app string) error { } if filter != "" { - // Need to use bash to pipe through grep - commandStr := fmt.Sprintf("%s %s | grep --line-buffered '%s'", CmdAdb, strings.Join(args, " "), filter) - logCmd = exec.Command("sh", "-c", commandStr) - } else { - logCmd = exec.Command(CmdAdb, args...) + // Filtering is now handled by runLogViewer + // to avoid shell injection vulnerabilities with sh -c | grep } + logCmd = exec.Command(CmdAdb, args...) } else { // iOS log stream command args := []string{"simctl", "spawn", udid, "log", "stream"} @@ -104,11 +102,10 @@ func streamLogs(deviceID, level, filter, app string) error { } if filter != "" { - commandStr := fmt.Sprintf("%s %s | grep --line-buffered '%s'", CmdXCrun, strings.Join(args, " "), filter) - logCmd = exec.Command("sh", "-c", commandStr) - } else { - logCmd = exec.Command(CmdXCrun, args...) + // Filtering is now handled by runLogViewer + // to avoid shell injection vulnerabilities with sh -c | grep } + logCmd = exec.Command(CmdXCrun, args...) } stdout, err := logCmd.StdoutPipe() @@ -121,7 +118,7 @@ func streamLogs(deviceID, level, filter, app string) error { return fmt.Errorf("failed to start log stream: %w", err) } - err = runLogViewer(stdout) + err = runLogViewer(stdout, filter) // Ensure the log command is killed when bubbletea exits if logCmd.Process != nil { diff --git a/cmd/manager.go b/cmd/manager.go index b2d3b31..6b1805e 100644 --- a/cmd/manager.go +++ b/cmd/manager.go @@ -59,7 +59,7 @@ func FindRunningDevice(deviceID string) (udid, name string, isAndroid bool, err return "", "", false, err } if found { - return u, n, m.Name() == "Android", nil + return u, n, m.Name() == NameAndroid, nil } } if deviceID == "" { diff --git a/cmd/root.go b/cmd/root.go index f34b039..f26ed7d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -151,7 +151,6 @@ func init() { rootCmd.AddCommand(listCmd) rootCmd.AddCommand(startCmd) rootCmd.AddCommand(stopCmd) - rootCmd.AddCommand(shutdownCmd) rootCmd.AddCommand(completionCmd) rootCmd.AddCommand(installCmd) rootCmd.AddCommand(uninstallCmd) diff --git a/cmd/ui.go b/cmd/ui.go index a2dbb93..e37d2e8 100644 --- a/cmd/ui.go +++ b/cmd/ui.go @@ -73,16 +73,16 @@ func ApplyTheme(theme string) { // FormatState returns a styled string for device state. func FormatState(state string) string { - if state == StateBooted || state == "Booted" || state == "device" { + if state == StateBooted || state == "device" { if state == "device" { - state = "Booted" // Normalize Android 'device' to 'Booted' + state = StateBooted // Normalize Android 'device' to 'Booted' } return StyleBooted.Render(state) } if state == "offline" { - state = "Offline" + state = StateShutdown } return StyleShutdown.Render(state) @@ -90,10 +90,10 @@ func FormatState(state string) string { // FormatPlatform returns a styled string for platform type. func FormatPlatform(platform string) string { - if platform == TypeIOSSimulator || platform == "iOS" { + if platform == TypeIOSSimulator || platform == NameIOS { return StyleIOS.Render(platform) } - if platform == TypeAndroidEmulator || platform == "Android" { + if platform == TypeAndroidEmulator || platform == NameAndroid { return StyleAndroid.Render(platform) } diff --git a/tests/config_test.go b/tests/config_test.go index debcc2b..7472c42 100644 --- a/tests/config_test.go +++ b/tests/config_test.go @@ -11,7 +11,10 @@ import ( func TestGetConfigDir(t *testing.T) { _ = NewTestHelpers(t) // sets up temp HOME via t.Setenv - configDir := cmd.GetConfigDir() + configDir, err := cmd.GetConfigDir() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } if configDir == "" { t.Error("Config directory should not be empty") } @@ -20,7 +23,10 @@ func TestGetConfigDir(t *testing.T) { func TestGetConfigPath(t *testing.T) { _ = NewTestHelpers(t) - configPath := cmd.GetConfigPath() + configPath, err := cmd.GetConfigPath() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } if configPath == "" { t.Error("Config path should not be empty") } @@ -143,7 +149,11 @@ func TestConfigFilePermissions(t *testing.T) { t.Fatalf("Failed to save device: %v", err) } - info, err := os.Stat(cmd.GetConfigPath()) + path, err := cmd.GetConfigPath() + if err != nil { + t.Fatalf("Failed to get config path: %v", err) + } + info, err := os.Stat(path) if err != nil { t.Fatalf("Failed to stat config file: %v", err) } diff --git a/tests/integration_test.go b/tests/integration_test.go index 7c42e1c..769f03b 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -150,7 +150,10 @@ func TestIntegration_ErrorHandling(t *testing.T) { h := NewTestHelpers(t) // Verify config dir is accessible under temp HOME. - configDir := cmd.GetConfigDir() + configDir, err := cmd.GetConfigDir() + if err != nil { + t.Fatalf("Failed to get config dir: %v", err) + } if configDir == "" { t.Error("Config directory should not be empty") } @@ -167,12 +170,15 @@ func TestIntegration_ErrorHandling(t *testing.T) { } // Corrupted config should return an error. - configPath := cmd.GetConfigPath() + configPath, err := cmd.GetConfigPath() + if err != nil { + t.Fatalf("Failed to get config path: %v", err) + } if err := os.WriteFile(configPath, []byte("invalid json"), 0o644); err != nil { t.Fatalf("Failed to write invalid config: %v", err) } - _, err := cmd.LoadConfig() + _, err = cmd.LoadConfig() if err == nil { t.Error("Expected error when loading corrupted config") } @@ -305,7 +311,11 @@ func TestSecurity_ConfigFilePermissions(t *testing.T) { t.Fatalf("Failed to save device: %v", err) } - info, err := os.Stat(cmd.GetConfigPath()) + configPath, err := cmd.GetConfigPath() + if err != nil { + t.Fatalf("Failed to get config path: %v", err) + } + info, err := os.Stat(configPath) if err != nil { t.Fatalf("Failed to stat config file: %v", err) } @@ -323,7 +333,11 @@ func TestSecurity_ConfigDirectory(t *testing.T) { t.Fatalf("Failed to save device: %v", err) } - info, err := os.Stat(cmd.GetConfigDir()) + configDir, err := cmd.GetConfigDir() + if err != nil { + t.Fatalf("Failed to get config dir: %v", err) + } + info, err := os.Stat(configDir) if err != nil { t.Fatalf("Failed to stat config directory: %v", err) } From 85730156e23ddcbb3af37acffeca64fb51193528 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Thu, 16 Jul 2026 17:33:49 +0700 Subject: [PATCH 4/7] docs: update README with merged stop/shutdown aliases --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 0806541..81c32e1 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,7 @@ sim last | `doctor` | - | Check all system dependencies (Xcode, Android SDK, ffmpeg). | | `list` | `l`, `ls` | List all available simulators and emulators. | | `start ` | `s` | Start a simulator or emulator by name or UDID. | -| `stop ` | `st` | Stop a running simulator or emulator. | -| `shutdown ` | `sd` | Shutdown a simulator or emulator. | +| `stop ` | `st`, `sd`, `shutdown` | Stop or shutdown a running simulator or emulator. | | `restart ` | `r` | Restart a simulator or emulator. | | `delete ` | `d`, `del` | **Permanently** delete a simulator or emulator. | | `erase ` | `reset` | Factory reset a device, wiping its data. | From 1e1af2f3422c6baf49b66cc33815588adfc344b5 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Thu, 16 Jul 2026 17:36:29 +0700 Subject: [PATCH 5/7] fix: resolve golangci-lint issues --- cmd/config.go | 1 + cmd/list_benchmark_test.go | 7 ++++++- cmd/logs.go | 13 ++++--------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cmd/config.go b/cmd/config.go index 2eccb1a..30fd3da 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -33,6 +33,7 @@ func GetConfigPath() (string, error) { if err != nil { return "", err } + return filepath.Join(dir, "config.json"), nil } diff --git a/cmd/list_benchmark_test.go b/cmd/list_benchmark_test.go index 0f138e7..01d7da7 100644 --- a/cmd/list_benchmark_test.go +++ b/cmd/list_benchmark_test.go @@ -1,3 +1,4 @@ +//nolint:testpackage package cmd import ( @@ -25,14 +26,18 @@ func (m *mockBenchmarkExecutor) Output(name string, args ...string) ([]byte, err if args[2] == "emulator-5554" { return []byte("Pixel_8_API_34\nOK\n"), nil } + return []byte("Unknown_AVD\nOK\n"), nil } } + return nil, nil } func (m *mockBenchmarkExecutor) Run(name string, args ...string) error { return nil } -func (m *mockBenchmarkExecutor) Start(name string, args ...string) (*exec.Cmd, error) { return nil, nil } +func (m *mockBenchmarkExecutor) Start(name string, args ...string) (*exec.Cmd, error) { + return nil, nil +} var benchmarkIOSOutput = []byte(`{ "devices" : { diff --git a/cmd/logs.go b/cmd/logs.go index f506bb3..e4384ad 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -45,7 +45,6 @@ func init() { logsCmd.Flags().StringVarP(&logApp, "app", "a", "", "Filter by app bundle ID (iOS) or package name (Android)") } -//nolint:gocyclo,cyclop func streamLogs(deviceID, level, filter, app string) error { udid, name, isAndroid, err := FindRunningDevice(deviceID) if err != nil { @@ -84,10 +83,8 @@ func streamLogs(deviceID, level, filter, app string) error { } } - if filter != "" { - // Filtering is now handled by runLogViewer - // to avoid shell injection vulnerabilities with sh -c | grep - } + // Filtering is now handled by runLogViewer + // to avoid shell injection vulnerabilities with sh -c | grep logCmd = exec.Command(CmdAdb, args...) } else { // iOS log stream command @@ -101,10 +98,8 @@ func streamLogs(deviceID, level, filter, app string) error { args = append(args, "--predicate", fmt.Sprintf("subsystem == \"%s\"", app)) } - if filter != "" { - // Filtering is now handled by runLogViewer - // to avoid shell injection vulnerabilities with sh -c | grep - } + // Filtering is now handled by runLogViewer + // to avoid shell injection vulnerabilities with sh -c | grep logCmd = exec.Command(CmdXCrun, args...) } From aca85aa0ad71411cc11ebea121fbc513c0e3a719 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Thu, 16 Jul 2026 17:45:25 +0700 Subject: [PATCH 6/7] refactor: apply copilot suggestions for constants and state formatting --- cmd/constants.go | 1 + cmd/create.go | 2 +- cmd/ui.go | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/constants.go b/cmd/constants.go index 6fbccdc..094e11e 100644 --- a/cmd/constants.go +++ b/cmd/constants.go @@ -4,6 +4,7 @@ const ( DarwinOS = "darwin" StateBooted = "Booted" StateShutdown = "Shutdown" + StateOffline = "Offline" TypeIOSSimulator = "iOS Simulator" TypeAndroidEmulator = "Android Emulator" PlatformIOS = "ios" diff --git a/cmd/create.go b/cmd/create.go index 263f7bf..c01916f 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -189,7 +189,7 @@ func CreateAndroidDevice(name, deviceType, runtimeID string) error { } // avdmanager prompts for custom hardware profile. We pipe "no\n" to it. - cmd := exec.Command("avdmanager", args...) + cmd := exec.Command(CmdAvdManager, args...) cmd.Stdin = strings.NewReader("no\n") if err := cmd.Run(); err != nil { diff --git a/cmd/ui.go b/cmd/ui.go index e37d2e8..ba7360f 100644 --- a/cmd/ui.go +++ b/cmd/ui.go @@ -82,7 +82,7 @@ func FormatState(state string) string { } if state == "offline" { - state = StateShutdown + state = StateOffline } return StyleShutdown.Render(state) From 975eae466eff000668d86470fa0a54471d6d0290 Mon Sep 17 00:00:00 2001 From: Annurdien Rasyid Date: Thu, 16 Jul 2026 19:42:16 +0700 Subject: [PATCH 7/7] fix: resolve copilot pull request feedback --- cmd/app.go | 13 +++++++------ cmd/create.go | 2 +- cmd/dashboard.go | 4 ++-- cmd/device.go | 5 +++++ cmd/log_viewer.go | 11 ++++++++++- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/cmd/app.go b/cmd/app.go index a1a5508..772fa2b 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -60,12 +60,13 @@ If no device is specified, the first booted device is used automatically.`, func findDeviceForAppInstall(deviceID, ext string) (udid, name string, isAndroid bool, err error) { switch ext { case ExtAPK: - u, n, isA, err := FindRunningDevice(deviceID) - if err != nil { - return "", "", true, err - } - if !isA { - return "", "", true, ErrAndroidEmulatorNotRunning + u, n := FindRunningAndroidEmulator(deviceID) + if u == "" { + if deviceID == "" { + return "", "", true, ErrAndroidEmulatorNotRunning + } + + return "", "", true, fmt.Errorf("device %q: %w", deviceID, ErrAndroidEmulatorNotRunning) } return u, n, true, nil diff --git a/cmd/create.go b/cmd/create.go index c01916f..6890c0d 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -290,7 +290,7 @@ func runCreateWizard() error { Title("Select Platform"). Options( huh.NewOption("iOS Simulator", PlatformIOS), - huh.NewOption("Android Emulator", "android"), + huh.NewOption("Android Emulator", PlatformAndroid), ). Value(&platform). Run() diff --git a/cmd/dashboard.go b/cmd/dashboard.go index 5b5868c..6a03e49 100644 --- a/cmd/dashboard.go +++ b/cmd/dashboard.go @@ -107,8 +107,8 @@ func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = true m.msg = "Stopping " + row[1] + "..." cmds = append(cmds, doActionCmd(func() error { - for _, m := range GetManagers() { - found, err := m.Stop(deviceID) + for _, mgr := range GetManagers() { + found, err := mgr.Stop(deviceID) if err != nil { return err } diff --git a/cmd/device.go b/cmd/device.go index 5b52e24..6290505 100644 --- a/cmd/device.go +++ b/cmd/device.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "runtime" "strings" "github.com/spf13/cobra" @@ -178,6 +179,10 @@ var cloneCmd = &cobra.Command{ ValidArgsFunction: validDeviceAndFileArgs, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { + if runtime.GOOS != DarwinOS { + return ErrIOSMacOnly + } + sourceDevice := args[0] newName := args[1] diff --git a/cmd/log_viewer.go b/cmd/log_viewer.go index a1403b4..3ae380b 100644 --- a/cmd/log_viewer.go +++ b/cmd/log_viewer.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "io" + "regexp" "strings" "github.com/charmbracelet/bubbles/viewport" @@ -21,6 +22,14 @@ type logViewerModel struct { } func runLogViewer(stdout io.ReadCloser, filter string) error { + var re *regexp.Regexp + if filter != "" { + var err error + if re, err = regexp.Compile(filter); err != nil { + return fmt.Errorf("invalid filter pattern: %w", err) + } + } + m := logViewerModel{ lines: []string{}, } @@ -30,7 +39,7 @@ func runLogViewer(stdout io.ReadCloser, filter string) error { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() - if filter != "" && !strings.Contains(line, filter) { + if re != nil && !re.MatchString(line) { continue } p.Send(logMsg(line))