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
3 changes: 3 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ screencapture は一方向であることに注意してください。画面を
# Boot an offline emulator or simulator
mobilecli device boot --device <device-id>

# Boot it with a visible window
mobilecli device boot --device <device-id> --headed

# Shutdown a running emulator or simulator
mobilecli device shutdown --device <device-id>

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ Note that screencapture is one way. You will have to use `io tap` commands to ta
# Boot an offline emulator or simulator
mobilecli device boot --device <device-id>

# Boot it with a visible window
mobilecli device boot --device <device-id> --headed

# Shutdown a running emulator or simulator
mobilecli device shutdown --device <device-id>

Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ mobilecli screencapture --device <device-id> --format mjpeg | ffplay -
# Boot an offline emulator or simulator
mobilecli device boot --device <device-id>

# Boot it with a visible window
mobilecli device boot --device <device-id> --headed

# Shutdown a running emulator or simulator
mobilecli device shutdown --device <device-id>

Expand Down
6 changes: 5 additions & 1 deletion cli/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,16 @@ var deviceFoldCmd = &cobra.Command{
},
}

var bootHeaded bool

var deviceBootCmd = &cobra.Command{
Use: "boot",
Short: "Boot a simulator or emulator",
Long: `Boots a specified offline simulator or emulator.`,
Long: `Boots a specified offline simulator or emulator. The device boots without a window; pass --headed to show it.`,
RunE: func(cmd *cobra.Command, args []string) error {
req := commands.BootRequest{
DeviceID: deviceId,
Headed: bootHeaded,
}

return runViaDaemon("cli.device.boot", req)
Expand Down Expand Up @@ -161,6 +164,7 @@ func init() {
deviceRebootCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to reboot")
deviceInfoCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to get info from")
deviceBootCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to boot")
deviceBootCmd.Flags().BoolVar(&bootHeaded, "headed", false, "show the simulator or emulator window")
deviceShutdownCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to shutdown")
orientationGetCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to get orientation from")
orientationSetCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to set orientation on")
Expand Down
3 changes: 3 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ var rootCmd = &cobra.Command{
# Boot an offline emulator/simulator device
mobilecli device boot --device <device-id>

# Boot it with a visible window
mobilecli device boot --device <device-id> --headed

# Shutdown a running emulator/simulator device
mobilecli device shutdown --device <device-id>

Expand Down
5 changes: 4 additions & 1 deletion commands/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package commands

import (
"fmt"

"github.com/mobile-next/mobilecli/devices"
)

// BootRequest represents the parameters for a boot command
type BootRequest struct {
DeviceID string `json:"deviceId"`
Headed bool `json:"headed,omitempty"`
}

// BootCommand boots the specified simulator or emulator
Expand All @@ -16,7 +19,7 @@ func BootCommand(req BootRequest) *CommandResponse {
return NewErrorResponse(fmt.Errorf("error finding device: %v", err))
}

err = targetDevice.Boot()
err = targetDevice.Boot(devices.BootOptions{Headed: req.Headed})
if err != nil {
return NewErrorResponse(fmt.Errorf("failed to boot device %s: %v", targetDevice.ID(), err))
}
Expand Down
32 changes: 32 additions & 0 deletions commands/boot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package commands

import (
"encoding/json"
"testing"
)

func decodeBootRequest(t *testing.T, params string) BootRequest {
t.Helper()

var req BootRequest
if err := json.Unmarshal([]byte(params), &req); err != nil {
t.Fatalf("failed to decode %s: %v", params, err)
}
return req
}

func TestBootRequestIsNotHeadedByDefault(t *testing.T) {
req := decodeBootRequest(t, `{"deviceId":"Pixel_9"}`)

if req.Headed {
t.Fatal("expected a boot without a window when headed is not given")
}
}

func TestBootRequestReadsHeaded(t *testing.T) {
req := decodeBootRequest(t, `{"deviceId":"Pixel_9","headed":true}`)

if !req.Headed {
t.Fatal("expected headed to be read from the request")
}
}
14 changes: 12 additions & 2 deletions devices/android.go
Original file line number Diff line number Diff line change
Expand Up @@ -822,8 +822,18 @@
}
}

// emulatorBootArgs returns the emulator arguments for booting an avd. the window
// is hidden unless a headed boot is requested.
func emulatorBootArgs(avdName string, opts BootOptions) []string {
args := []string{"-netdelay", "none", "-netspeed", "full", "-avd", avdName}

Check failure on line 828 in devices/android.go

View workflow job for this annotation

GitHub Actions / lint

string `full` has 3 occurrences, make it a constant (goconst)
if !opts.Headed {
args = append(args, "-qt-hide-window")
}
return args
}

// Boot launches an offline Android emulator and waits for it to be ready
func (d *AndroidDevice) Boot() error {
func (d *AndroidDevice) Boot(opts BootOptions) error {
if d.state != StateOffline {
return fmt.Errorf("emulator is already running")
}
Expand All @@ -834,7 +844,7 @@
defer cancel()

// launch emulator in background without context (so it persists after function returns)
cmd := exec.CommandContext(context.Background(), getEmulatorPath(), "-netdelay", "none", "-netspeed", "full", "-avd", d.id, "-qt-hide-window")
cmd := exec.CommandContext(context.Background(), getEmulatorPath(), emulatorBootArgs(d.id, opts)...)
err := cmd.Start()
if err != nil {
return fmt.Errorf("failed to start emulator: %w", err)
Expand Down
33 changes: 33 additions & 0 deletions devices/android_boot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package devices

import (
"slices"
"testing"
)

func TestEmulatorBootsHiddenByDefault(t *testing.T) {
args := emulatorBootArgs("Pixel_9", BootOptions{})

if !slices.Contains(args, "-qt-hide-window") {
t.Fatalf("expected a hidden window by default, got %v", args)
}
}

func TestEmulatorBootsWithWindowWhenHeaded(t *testing.T) {
args := emulatorBootArgs("Pixel_9", BootOptions{Headed: true})

if slices.Contains(args, "-qt-hide-window") {
t.Fatalf("expected a visible window when headed, got %v", args)
}
}

func TestEmulatorBootTargetsTheRequestedAvd(t *testing.T) {
for _, opts := range []BootOptions{{}, {Headed: true}} {
args := emulatorBootArgs("Pixel_9", opts)

avdFlag := slices.Index(args, "-avd")
if avdFlag == -1 || avdFlag+1 >= len(args) || args[avdFlag+1] != "Pixel_9" {
t.Fatalf("expected -avd Pixel_9, got %v", args)
}
}
}
10 changes: 8 additions & 2 deletions devices/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ const (
FormatAVC = "avc"
)

// BootOptions controls how a simulator/emulator is booted
type BootOptions struct {
// Headed shows the device window; by default the device boots hidden
Headed bool
}

type ControllableDevice interface {
ID() string
Name() string
Expand All @@ -193,8 +199,8 @@ type ControllableDevice interface {

TakeScreenshot(opts ScreenshotOptions) ([]byte, error)
Reboot() error
Boot() error // boot simulator/emulator
Shutdown() error // shutdown simulator/emulator
Boot(opts BootOptions) error // boot simulator/emulator
Shutdown() error // shutdown simulator/emulator
Tap(x, y int) error
LongPress(x, y, duration int) error
Swipe(x1, y1, x2, y2, duration int) error
Expand Down
2 changes: 1 addition & 1 deletion devices/ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ func (d *IOSDevice) Reboot() error {
return nil
}

func (d *IOSDevice) Boot() error {
func (d *IOSDevice) Boot(_ BootOptions) error {
return fmt.Errorf("boot is not supported for real iOS devices")
}

Expand Down
2 changes: 1 addition & 1 deletion devices/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ func (r *RemoteDevice) TerminateApp(bundleID string) error {
return r.fireRPC("device.apps.terminate", params{"bundleId": bundleID})
}

func (r *RemoteDevice) Boot() error {
func (r *RemoteDevice) Boot(_ BootOptions) error {
return r.fireRPC("device.boot", params{})
}

Expand Down
64 changes: 50 additions & 14 deletions devices/simulator.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ func (s *SimulatorDevice) getState() (string, error) {
}

// Boot boots the iOS simulator
func (s *SimulatorDevice) Boot() error {
func (s *SimulatorDevice) Boot(opts BootOptions) error {
state, err := s.getState()
if err != nil {
return fmt.Errorf("failed to get simulator state: %w", err)
Expand All @@ -445,30 +445,66 @@ func (s *SimulatorDevice) Boot() error {

if state == "Booting" {
utils.Verbose("Simulator is already booting, waiting for boot to complete...")
output, err := runSimctl("bootstatus", s.UDID)
} else {
utils.Verbose("Booting simulator %s...", s.UDID)
output, err := runSimctl("boot", s.UDID)
if err != nil {
return fmt.Errorf("failed to wait for boot status: %w\n%s", err, output)
return fmt.Errorf("failed to boot simulator %s: %w\n%s", s.UDID, err, output)
}

utils.Verbose("Simulator booted successfully")
s.Simulator.State = simStateBooted
return nil
utils.Verbose("Waiting for simulator to finish booting...")
}

utils.Verbose("Booting simulator %s...", s.UDID)
output, err := runSimctl("boot", s.UDID)
if err != nil {
return fmt.Errorf("failed to boot simulator %s: %w\n%s", s.UDID, err, output)
}

utils.Verbose("Waiting for simulator to finish booting...")
output, err = runSimctl("bootstatus", s.UDID)
output, err := runSimctl("bootstatus", s.UDID)
if err != nil {
return fmt.Errorf("failed to wait for boot status %s: %w\n%s", s.UDID, err, output)
}

utils.Verbose("Simulator booted successfully")
s.Simulator.State = simStateBooted

if opts.Headed {
return s.showWindow()
}

return nil
}

// simulatorWindowCommand returns the "open" arguments that show the simulator's
// window, using the app that ships with the Xcode at developerDir. Xcode up to 26
// ships Simulator.app; Xcode 27 replaced it with Device Hub.
func simulatorWindowCommand(developerDir, udid string) ([]string, error) {
simulatorApp := filepath.Join(developerDir, "Applications", "Simulator.app")
if _, err := os.Stat(simulatorApp); err == nil {
return []string{simulatorApp, "--args", "-CurrentDeviceUDID", udid}, nil
}

// Device Hub opens on its last selected device, so ask it for this device's window
deviceHubApp := filepath.Join(filepath.Dir(developerDir), "Applications", "DeviceHub.app")
if _, err := os.Stat(deviceHubApp); err == nil {
return []string{"-a", deviceHubApp, "devices://device/open?id=" + udid}, nil
}

return nil, fmt.Errorf("neither Simulator.app nor DeviceHub.app found in the Xcode at %s", developerDir)
}

// showWindow opens the app that shows simulator windows: Simulator.app, or Device Hub
func (s *SimulatorDevice) showWindow() error {
output, err := exec.CommandContext(context.Background(), "xcode-select", "-p").Output()
if err != nil {
return fmt.Errorf("simulator %s booted, but failed to locate Xcode to show its window: %w", s.UDID, err)
}

openArgs, err := simulatorWindowCommand(strings.TrimSpace(string(output)), s.UDID)
if err != nil {
return fmt.Errorf("simulator %s booted, but failed to show its window: %w", s.UDID, err)
}

output, err = exec.CommandContext(context.Background(), "open", openArgs...).CombinedOutput()
if err != nil {
return fmt.Errorf("simulator %s booted, but failed to show its window: %w\n%s", s.UDID, err, output)
}

return nil
}

Expand Down
76 changes: 76 additions & 0 deletions devices/simulator_window_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package devices

import (
"os"
"path/filepath"
"slices"
"testing"
)

const windowTestUDID = "13ED70EB-D993-4E69-B6AD-9764AD4D5E3A"

// xcodeWithApps creates a fake Xcode.app holding the given apps (paths relative
// to Xcode.app/Contents) and returns its developer directory
func xcodeWithApps(t *testing.T, apps ...string) string {
t.Helper()

contents := filepath.Join(t.TempDir(), "Xcode.app", "Contents")
developerDir := filepath.Join(contents, "Developer")
for _, dir := range append(apps, "Developer") {
if err := os.MkdirAll(filepath.Join(contents, dir), 0o750); err != nil {
t.Fatal(err)
}
}
return developerDir
}

func TestWindowOpensSimulatorAppOnTheBootedDevice(t *testing.T) {
developerDir := xcodeWithApps(t, "Developer/Applications/Simulator.app")

args, err := simulatorWindowCommand(developerDir, windowTestUDID)
if err != nil {
t.Fatal(err)
}

expected := []string{filepath.Join(developerDir, "Applications", "Simulator.app"), "--args", "-CurrentDeviceUDID", windowTestUDID}
if !slices.Equal(args, expected) {
t.Fatalf("expected %v, got %v", expected, args)
}
}

func TestWindowOpensDeviceHubOnTheBootedDeviceWhenXcodeHasNoSimulatorApp(t *testing.T) {
developerDir := xcodeWithApps(t, "Applications/DeviceHub.app")

args, err := simulatorWindowCommand(developerDir, windowTestUDID)
if err != nil {
t.Fatal(err)
}

deviceHubApp := filepath.Join(filepath.Dir(developerDir), "Applications", "DeviceHub.app")
expected := []string{"-a", deviceHubApp, "devices://device/open?id=" + windowTestUDID}
if !slices.Equal(args, expected) {
t.Fatalf("expected %v, got %v", expected, args)
}
}

func TestWindowFailsWhenXcodeHasNoSimulatorWindowApp(t *testing.T) {
developerDir := xcodeWithApps(t)

if _, err := simulatorWindowCommand(developerDir, windowTestUDID); err == nil {
t.Fatal("expected an error when neither Simulator.app nor DeviceHub.app exists")
}
}

func TestWindowPrefersSimulatorAppWhenXcodeHasBothApps(t *testing.T) {
developerDir := xcodeWithApps(t, "Developer/Applications/Simulator.app", "Applications/DeviceHub.app")

args, err := simulatorWindowCommand(developerDir, windowTestUDID)
if err != nil {
t.Fatal(err)
}

simulatorApp := filepath.Join(developerDir, "Applications", "Simulator.app")
if args[0] != simulatorApp {
t.Fatalf("expected Simulator.app to be opened, got %v", args)
}
}
Loading
Loading