Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ recording_*.mp4
*.png

# Temporary files
/tmp/
/tmp/coverage.out
coverage.html
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <device>` | `s` | Start a simulator or emulator by name or UDID. |
| `stop <device>` | `st` | Stop a running simulator or emulator. |
| `shutdown <device>` | `sd` | Shutdown a simulator or emulator. |
| `stop <device>` | `st`, `sd`, `shutdown` | Stop or shutdown a running simulator or emulator. |
| `restart <device>` | `r` | Restart a simulator or emulator. |
| `delete <device>` | `d`, `del` | **Permanently** delete a simulator or emulator. |
| `erase <device>` | `reset` | Factory reset a device, wiping its data. |
Expand Down
223 changes: 223 additions & 0 deletions cmd/android_manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
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 NameAndroid
}

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
}

if _, err := m.Stop(deviceID); err != nil {
PrintInfo(fmt.Sprintf("Warning: failed to stop device before restart: %v", err))
}

return m.Start(deviceID, false)
}

func (m *AndroidManager) Delete(deviceID string) (bool, error) {
if !DoesAndroidAVDExist(deviceID) {
return false, nil
}

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)
}

return true, nil
}

func (m *AndroidManager) Erase(deviceID string) (bool, error) {
if !DoesAndroidAVDExist(deviceID) {
return false, nil
}

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 {
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 != ""
}
59 changes: 11 additions & 48 deletions cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +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 := FindRunningAndroidEmulator(deviceID)
if u == "" {
if deviceID == "" {
return "", "", true, ErrAndroidEmulatorNotRunning
}

return "", "", true, fmt.Errorf("device %q: %w", deviceID, ErrAndroidEmulatorNotRunning)
}

Expand All @@ -81,21 +76,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)
Expand Down Expand Up @@ -136,33 +125,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 {
Expand Down
17 changes: 13 additions & 4 deletions cmd/completion.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"fmt"
"os"
"runtime"

Expand Down Expand Up @@ -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))
}
}
},
}
Expand Down
Loading
Loading