diff --git a/README.md b/README.md index cec553d1..ee89d07b 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,12 @@ mobilecli apps foreground --device # Launch an app mobilecli apps launch --device +# Launch an app with arguments and environment variables (iOS) +mobilecli apps launch --device --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 --device --arg featureX=true --arg apiUrl=https://staging.example.com + # Terminate an app mobilecli apps terminate --device diff --git a/cli/apps.go b/cli/apps.go index 1dc797f5..59204319 100644 --- a/cli/apps.go +++ b/cli/apps.go @@ -1,6 +1,7 @@ package cli import ( + "fmt" "strings" "github.com/mobile-next/mobilecli/commands" @@ -29,17 +30,42 @@ var appsLaunchCmd = &cobra.Command{ } } + 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", @@ -164,6 +190,8 @@ func init() { 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") diff --git a/cli/apps_test.go b/cli/apps_test.go new file mode 100644 index 00000000..2142102f --- /dev/null +++ b/cli/apps_test.go @@ -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) + } + } +} diff --git a/cli/flags.go b/cli/flags.go index 0d0fa0fd..64abcb58 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -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 diff --git a/commands/apps.go b/commands/apps.go index 86404cf1..c9ff25e8 100644 --- a/commands/apps.go +++ b/commands/apps.go @@ -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 @@ -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)) } diff --git a/devices/android.go b/devices/android.go index 6cd21260..130cf796 100644 --- a/devices/android.go +++ b/devices/android.go @@ -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) { @@ -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 { @@ -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)) } @@ -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 { diff --git a/devices/android_launch_test.go b/devices/android_launch_test.go index 3e8ed7f7..d132287b 100644 --- a/devices/android_launch_test.go +++ b/devices/android_launch_test.go @@ -140,7 +140,7 @@ 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) @@ -148,9 +148,34 @@ func Test_launchArgs_clearsTheTaskWhenTheAppIsNotRunning(t *testing.T) { } 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) + } + } +} diff --git a/devices/common.go b/devices/common.go index 406d13ba..3c150778 100644 --- a/devices/common.go +++ b/devices/common.go @@ -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. diff --git a/devices/ios.go b/devices/ios.go index cf14c8cf..8be40728 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -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 { diff --git a/devices/remote.go b/devices/remote.go index 21d286cc..e3373844 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -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) } diff --git a/devices/simulator.go b/devices/simulator.go index c4c114aa..098eee5e 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -13,6 +13,7 @@ import ( "path/filepath" "regexp" "runtime" + "sort" "strconv" "strings" "syscall" @@ -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) @@ -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 diff --git a/devices/simulator_launch_test.go b/devices/simulator_launch_test.go new file mode 100644 index 00000000..eb277bdd --- /dev/null +++ b/devices/simulator_launch_test.go @@ -0,0 +1,63 @@ +package devices + +import ( + "reflect" + "testing" +) + +func Test_simctlLaunchArgs(t *testing.T) { + tests := []struct { + name string + opts LaunchOptions + want []string + }{ + { + name: "no options", + opts: LaunchOptions{}, + want: []string{"launch", "UDID", "com.example.app"}, + }, + { + name: "arguments follow the bundle id", + opts: LaunchOptions{Arguments: []string{"-FeatureX", "YES"}}, + want: []string{"launch", "UDID", "com.example.app", "-FeatureX", "YES"}, + }, + { + name: "locales come before arguments", + opts: LaunchOptions{Locales: []string{"fr-FR"}, Arguments: []string{"--uitesting"}}, + want: []string{"launch", "UDID", "com.example.app", "-AppleLanguages", "(fr-FR)", "--uitesting"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := simctlLaunchArgs("UDID", "com.example.app", tt.opts) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("simctlLaunchArgs() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_simctlLaunchEnv(t *testing.T) { + got := simctlLaunchEnv(map[string]string{"B": "2", "A": "x=y", "EMPTY": ""}) + want := []string{"SIMCTL_CHILD_A=x=y", "SIMCTL_CHILD_B=2", "SIMCTL_CHILD_EMPTY="} + if !reflect.DeepEqual(got, want) { + t.Errorf("simctlLaunchEnv() = %v, want %v", got, want) + } + + if got := simctlLaunchEnv(nil); len(got) != 0 { + t.Errorf("simctlLaunchEnv(nil) = %v, want empty", got) + } +} + +func Test_LaunchApp_EnvAndBareArgumentsRejectedOnAndroid(t *testing.T) { + device := &AndroidDevice{} + + if err := device.LaunchApp("com.example.app", LaunchOptions{Arguments: []string{"-FeatureX"}}); err == nil { + t.Fatal("expected error for an argument without '=', got nil") + } + + if err := device.LaunchApp("com.example.app", LaunchOptions{Env: map[string]string{"A": "1"}}); err == nil { + t.Fatal("expected error for env, got nil") + } +} diff --git a/server/server.go b/server/server.go index 6ba8f01b..d9bdb19b 100644 --- a/server/server.go +++ b/server/server.go @@ -764,10 +764,12 @@ type DumpUIParams struct { } type AppsLaunchParams 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"` } type AppsTerminateParams struct { @@ -1098,6 +1100,8 @@ func handleAppsLaunch(params json.RawMessage) (any, error) { BundleID: appsLaunchParams.BundleID, Locales: appsLaunchParams.Locales, Activity: appsLaunchParams.Activity, + Args: appsLaunchParams.Args, + Env: appsLaunchParams.Env, } response := commands.LaunchAppCommand(req) diff --git a/skills/mobilecli/reference.md b/skills/mobilecli/reference.md index 40851c7e..3dc95263 100644 --- a/skills/mobilecli/reference.md +++ b/skills/mobilecli/reference.md @@ -51,6 +51,10 @@ All commands support the global `--device ` flag to specify the target devic * **Launch / Terminate**: ```bash mobilecli apps launch --device + # iOS: repeat --arg and --env as needed + mobilecli apps launch --device --arg -FeatureX --arg YES --env API_URL=https://staging.example.com + # Android: each --arg is key=value and is passed as a string intent extra (--es key value) + mobilecli apps launch --device --arg featureX=true mobilecli apps terminate --device ``` * **Install / Uninstall**: