Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,12 @@ mobilecli apps foreground --device <device-id>
# Launch an app
mobilecli apps launch <bundle-id> --device <device-id>

# Launch an app with arguments and environment variables (iOS)
mobilecli apps launch <bundle-id> --device <device-id> --arg -FeatureX --arg YES --env API_URL=https://staging.example.com

# Launch an app with string intent extras (Android, each --arg is key=value and becomes --es key value)
mobilecli apps launch <package-name> --device <device-id> --arg featureX=true --arg apiUrl=https://staging.example.com

# Terminate an app
mobilecli apps terminate <bundle-id> --device <device-id>

Expand Down
28 changes: 28 additions & 0 deletions cli/apps.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"fmt"
"strings"

"github.com/mobile-next/mobilecli/commands"
Expand Down Expand Up @@ -29,17 +30,42 @@
}
}

env, err := parseLaunchEnv(launchEnv)
if err != nil {
return err
}

req := commands.AppRequest{
DeviceID: deviceId,
BundleID: args[0],
Locales: locales,
Activity: activity,
Args: launchArgs,
Env: env,
}

return runViaDaemon("cli.apps.launch", req)
},
}

// parseLaunchEnv turns repeated --env KEY=VALUE flags into a map. The value may
// contain '=' and may be empty.
func parseLaunchEnv(pairs []string) (map[string]string, error) {
if len(pairs) == 0 {
return nil, nil
}

env := make(map[string]string, len(pairs))
for _, pair := range pairs {
key, value, found := strings.Cut(pair, "=")
if !found || key == "" {
return nil, fmt.Errorf("invalid --env %q, expected KEY=VALUE", pair)
}
env[key] = value
}
return env, nil
}

var appsTerminateCmd = &cobra.Command{
Use: "terminate [bundle_id]",
Short: "Terminate an app on a device",
Expand All @@ -56,7 +82,7 @@
}

var appsListCmd = &cobra.Command{
Use: "list",

Check failure on line 85 in cli/apps.go

View workflow job for this annotation

GitHub Actions / lint

string `list` has 4 occurrences, make it a constant (goconst)
Short: "List installed apps on a device",
Long: `Lists all applications installed on the specified device.`,
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -164,6 +190,8 @@
appsLaunchCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to launch app on")
appsLaunchCmd.Flags().StringVar(&locale, "locale", "", "Comma-separated BCP 47 locale tags (e.g., fr-FR,en-GB)")
appsLaunchCmd.Flags().StringVar(&activity, "activity", "", "Android activity to launch (e.g. .DebugActivity or com.example/.DebugActivity)")
appsLaunchCmd.Flags().StringArrayVar(&launchArgs, "arg", nil, "Argument to pass to the app, repeat for more. On Android it must be key=value and is passed as a string intent extra")
appsLaunchCmd.Flags().StringArrayVar(&launchEnv, "env", nil, "Environment variable KEY=VALUE to set for the app, repeat for more (iOS only)")
appsTerminateCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to terminate app on")
appsListCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to list apps from")
appsInstallCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to install app on")
Expand Down
23 changes: 23 additions & 0 deletions cli/apps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cli

import (
"reflect"
"testing"
)

func Test_parseLaunchEnv(t *testing.T) {
got, err := parseLaunchEnv([]string{"API_URL=https://x.test/?a=b", "EMPTY="})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := map[string]string{"API_URL": "https://x.test/?a=b", "EMPTY": ""}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseLaunchEnv() = %v, want %v", got, want)
}

for _, bad := range []string{"NOEQUALS", "=value"} {
if _, err := parseLaunchEnv([]string{bad}); err == nil {
t.Errorf("parseLaunchEnv(%q): expected error, got nil", bad)
}
}
}
6 changes: 4 additions & 2 deletions cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ var (
deviceType string

// for apps launch command
locale string
activity string
locale string
activity string
launchArgs []string
launchEnv []string

// for agent install command
agentForce bool
Expand Down
23 changes: 18 additions & 5 deletions commands/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import (

// AppRequest represents the parameters for app-related commands
type AppRequest struct {
DeviceID string `json:"deviceId"`
BundleID string `json:"bundleId"`
Locales []string `json:"locales,omitempty"`
Activity string `json:"activity,omitempty"`
DeviceID string `json:"deviceId"`
BundleID string `json:"bundleId"`
Locales []string `json:"locales,omitempty"`
Activity string `json:"activity,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
}

// LaunchAppCommand launches an app on the specified device
Expand All @@ -28,7 +30,18 @@ func LaunchAppCommand(req AppRequest) *CommandResponse {
return NewErrorResponse(fmt.Errorf("error finding device: %v", err))
}

err = targetDevice.LaunchApp(req.BundleID, devices.LaunchOptions{Locales: req.Locales, Activity: req.Activity})
for k := range req.Env {
if k == "" || strings.Contains(k, "=") {
return NewErrorResponse(fmt.Errorf("invalid environment variable name: %q", k))
}
}

err = targetDevice.LaunchApp(req.BundleID, devices.LaunchOptions{
Locales: req.Locales,
Activity: req.Activity,
Arguments: req.Args,
Env: req.Env,
})
if err != nil {
return NewErrorResponse(fmt.Errorf("failed to launch app on device %s: %v", targetDevice.ID(), err))
}
Expand Down
29 changes: 26 additions & 3 deletions devices/android.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,14 @@ func (d *AndroidDevice) resolveLauncherActivity(bundleID string) (string, error)
}

func (d *AndroidDevice) LaunchApp(bundleID string, opts LaunchOptions) error {
extras, err := launchExtras(opts.Arguments)
if err != nil {
return err
}
if len(opts.Env) > 0 {
return fmt.Errorf("--env is not supported on Android")
}

if len(opts.Locales) > 0 {
for _, l := range opts.Locales {
if !validLocaleTag.MatchString(l) {
Expand All @@ -492,7 +500,6 @@ func (d *AndroidDevice) LaunchApp(bundleID string, opts LaunchOptions) error {
}

var component string
var err error
if opts.Activity != "" {
component, err = buildLaunchComponent(bundleID, opts.Activity)
} else {
Expand All @@ -502,7 +509,7 @@ func (d *AndroidDevice) LaunchApp(bundleID string, opts LaunchOptions) error {
return err
}

output, err := d.runAdbShell(launchArgs(component, d.isAppRunning(bundleID))...)
output, err := d.runAdbShell(launchArgs(component, d.isAppRunning(bundleID), extras)...)
if err != nil {
return fmt.Errorf("failed to launch app %s: %w\nOutput: %s", bundleID, err, string(output))
}
Expand All @@ -515,14 +522,30 @@ func (d *AndroidDevice) LaunchApp(bundleID string, opts LaunchOptions) error {
// permission prompt, stays on top: a plain start just resumes it. When the app isn't
// running, clear the task so the launch shows the app. When it is running, keep the
// task so launching brings the app to the front as it was.
func launchArgs(component string, isRunning bool) []string {
func launchArgs(component string, isRunning bool, extras []string) []string {
args := []string{"am", "start", "--display", defaultDisplayID}
if !isRunning {
args = append(args, "--activity-clear-task")
}
args = append(args, extras...)
return append(args, "-n", component)
}

// launchExtras turns key=value launch arguments into `am start --es key value`
// string extras, which the app reads from its launch intent. The value may
// contain '=' and may be empty.
func launchExtras(arguments []string) ([]string, error) {
extras := make([]string, 0, len(arguments)*3)
for _, argument := range arguments {
key, value, found := strings.Cut(argument, "=")
if !found || key == "" {
return nil, fmt.Errorf("invalid --arg %q, on Android each argument must be key=value", argument)
}
extras = append(extras, "--es", key, value)
}
return extras, nil
}

// isAppRunning reports whether the package has a live process. pidof exits non-zero
// when there is none.
func (d *AndroidDevice) isAppRunning(bundleID string) bool {
Expand Down
29 changes: 27 additions & 2 deletions devices/android_launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,42 @@ func Test_adbShellArgs_keepsEveryQueryParameterOfAURL(t *testing.T) {
}

func Test_launchArgs_clearsTheTaskWhenTheAppIsNotRunning(t *testing.T) {
got := strings.Join(launchArgs("com.example/.Main", false), " ")
got := strings.Join(launchArgs("com.example/.Main", false, nil), " ")
want := "am start --display " + defaultDisplayID + " --activity-clear-task -n com.example/.Main"
if got != want {
t.Fatalf("launchArgs() = %q, want %q", got, want)
}
}

func Test_launchArgs_keepsTheTaskWhenTheAppIsRunning(t *testing.T) {
got := strings.Join(launchArgs("com.example/.Main", true), " ")
got := strings.Join(launchArgs("com.example/.Main", true, nil), " ")
want := "am start --display " + defaultDisplayID + " -n com.example/.Main"
if got != want {
t.Fatalf("launchArgs() = %q, want %q", got, want)
}
}

func Test_launchArgs_passesExtrasBeforeTheComponent(t *testing.T) {
got := strings.Join(launchArgs("com.example/.Main", true, []string{"--es", "flag", "on"}), " ")
want := "am start --display " + defaultDisplayID + " --es flag on -n com.example/.Main"
if got != want {
t.Fatalf("launchArgs() = %q, want %q", got, want)
}
}

func Test_launchExtras(t *testing.T) {
got, err := launchExtras([]string{"apiUrl=https://x.test/?a=b", "empty="})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "--es apiUrl https://x.test/?a=b --es empty "
if strings.Join(got, " ") != want {
t.Errorf("launchExtras() = %q, want %q", strings.Join(got, " "), want)
}

for _, bad := range []string{"-FeatureX", "=value"} {
if _, err := launchExtras([]string{bad}); err == nil {
t.Errorf("launchExtras(%q): expected error, got nil", bad)
}
}
}
9 changes: 7 additions & 2 deletions devices/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,14 @@ type FileEntry struct {

// LaunchOptions carries optional parameters for launching an app.
// Activity is Android-only; passing it to an iOS device is an error.
// Arguments are process arguments on iOS. On Android each one must be key=value
// and is passed as a string intent extra. Env is iOS-only; passing it to an
// Android device is an error.
type LaunchOptions struct {
Locales []string
Activity string
Locales []string
Activity string
Arguments []string
Env map[string]string
}

// DumpOptions controls what a UI dump includes.
Expand Down
6 changes: 6 additions & 0 deletions devices/ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,12 @@ func (d *IOSDevice) LaunchApp(bundleID string, launchOpts LaunchOptions) error {
if len(launchOpts.Locales) > 0 {
args = append(args, "-AppleLanguages", "("+strings.Join(launchOpts.Locales, ", ")+")")
}
for _, a := range launchOpts.Arguments {
args = append(args, a)
}
for k, v := range launchOpts.Env {
envs[k] = v
}

pid, err := pControl.LaunchAppWithArgs(bundleID, args, envs, opts)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions devices/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ func (r *RemoteDevice) LaunchApp(bundleID string, opts LaunchOptions) error {
if opts.Activity != "" {
p["activity"] = opts.Activity
}
if len(opts.Arguments) > 0 {
p["args"] = opts.Arguments
}
if len(opts.Env) > 0 {
p["env"] = opts.Env
}
return r.fireRPC("device.apps.launch", p)
}

Expand Down
38 changes: 35 additions & 3 deletions devices/simulator.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
Expand Down Expand Up @@ -127,8 +128,17 @@ func (s SimulatorDevice) Reboot() error {

// runSimctl executes xcrun simctl with the provided arguments
func runSimctl(args ...string) ([]byte, error) {
return runSimctlWithEnv(nil, args...)
}

// runSimctlWithEnv runs simctl with extra environment variables added to the
// current environment.
func runSimctlWithEnv(env []string, args ...string) ([]byte, error) {
fullArgs := append([]string{"simctl"}, args...)
cmd := exec.CommandContext(context.Background(), "xcrun", fullArgs...)
if len(env) > 0 {
cmd.Env = append(os.Environ(), env...)
}
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to execute xcrun simctl command: %w", err)
Expand Down Expand Up @@ -276,12 +286,34 @@ func (s SimulatorDevice) LaunchApp(bundleID string, opts LaunchOptions) error {
if opts.Activity != "" {
return fmt.Errorf("--activity is not supported on iOS")
}
args := []string{"launch", s.UDID, bundleID}
_, err := runSimctlWithEnv(simctlLaunchEnv(opts.Env), simctlLaunchArgs(s.UDID, bundleID, opts)...)
return err
}

// simctlLaunchArgs builds the `simctl launch` command line. Everything after the
// bundle id is passed to the app as its arguments.
func simctlLaunchArgs(udid, bundleID string, opts LaunchOptions) []string {
args := []string{"launch", udid, bundleID}
if len(opts.Locales) > 0 {
args = append(args, "-AppleLanguages", "("+strings.Join(opts.Locales, ", ")+")")
}
_, err := runSimctl(args...)
return err
return append(args, opts.Arguments...)
}

// simctlLaunchEnv turns app environment variables into the SIMCTL_CHILD_ form
// that simctl launch passes to the launched app, sorted for stable output.
func simctlLaunchEnv(env map[string]string) []string {
keys := make([]string, 0, len(env))
for k := range env {
keys = append(keys, k)
}
sort.Strings(keys)

result := make([]string, 0, len(keys))
for _, k := range keys {
result = append(result, "SIMCTL_CHILD_"+k+"="+env[k])
}
return result
}

// TerminateApp stops a running app. `xcrun simctl terminate` exits with
Expand Down
Loading
Loading