Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ screenshot.png
**/node_modules
**/coverage*.out
**/coverage*.html
test/coverage
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@ build:
go mod tidy
CGO_ENABLED=0 go build -ldflags="-s -w"

build-cover:
go mod tidy
CGO_ENABLED=0 go build -ldflags="-s -w" -cover

test:
go test ./... -v -race

test-cover: build
test-cover: build-cover
go test ./... -v -race -cover -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html

test-e2e: build-cover
rm -rf test/coverage
(cd test && npm run test-simulator)
go tool covdata textfmt -i=test/coverage -o cover.out
go tool cover -func=cover.out

lint:
$(shell go env GOPATH)/bin/golangci-lint run

Expand Down
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ A universal command-line tool for managing iOS and Android devices, simulators,
- **Multiple Output Formats**: Save screenshots as PNG or JPEG with quality control
- **Screencapture video streaming**: Stream mjpeg/h264 video directly from device
- **Device Control**: Reboot devices, tap screen coordinates, press hardware buttons
- **App Management**: Launch app, terminate apps, install and uninstall
- **App Management**: Launch, terminate, install, uninstall, list, and get foreground apps

### 🎯 Platform Support

Expand Down Expand Up @@ -75,7 +75,7 @@ make build

Xcode is required. Make sure you have it installed with the runtimes relevant for you installed. You will have to create Simulators and have them booted before `mobilecli` can use them.

`mobilecli` will automatically install an agent on the device that is required for functionalities such as opening a url, tapping on buttons and streaming screen capture.
`mobilecli` will automatically install an agent on the device that is required for functions such as tapping on elements, pressing buttons buttons and streaming screen capture.

#### 🤖 For Android Support
```bash
Expand Down Expand Up @@ -186,6 +186,40 @@ mobilecli io text --device <device-id> 'hello world'
- `VOLUME_UP`, `VOLUME_DOWN` - Volume up and down
- `DPAD_UP`, `DPAD_DOWN`, `DPAD_LEFT`, `DPAD_RIGHT`, `DPAD_CENTER` - D-pad controls (Android only)

### App Management 📱

```bash
# List installed apps on device
mobilecli apps list --device <device-id>

# Get currently foreground app
mobilecli apps foreground --device <device-id>

# Launch an app
mobilecli apps launch <bundle-id> --device <device-id>

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

# Install an app (.apk for Android, .ipa for iOS, .zip for iOS Simulator)
mobilecli apps install <path> --device <device-id>

# Uninstall an app
mobilecli apps uninstall <bundle-id> --device <device-id>
```

Example output for `apps foreground`:
```json
{
"status": "ok",
"data": {
"packageName": "com.example.app",
"appName": "Example App",
"version": "1.0.0"
}
}
```

## HTTP API 🔌

***mobilecli*** provides an http interface for all the functionality that is available through command line. As a matter of fact, it is preferable to
Expand Down
20 changes: 20 additions & 0 deletions cli/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@ var appsUninstallCmd = &cobra.Command{
},
}

var appsForegroundCmd = &cobra.Command{
Use: "foreground",
Short: "Get the currently foreground app on a device",
Long: `Returns information about the app currently in the foreground on the specified device.`,
RunE: func(cmd *cobra.Command, args []string) error {
req := commands.ForegroundAppRequest{
DeviceID: deviceId,
}

response := commands.ForegroundAppCommand(req)
printJson(response)
if response.Status == "error" {
return fmt.Errorf("%s", response.Error)
}
return nil
},
}

func init() {
rootCmd.AddCommand(appsCmd)

Expand All @@ -119,10 +137,12 @@ func init() {
appsCmd.AddCommand(appsListCmd)
appsCmd.AddCommand(appsInstallCmd)
appsCmd.AddCommand(appsUninstallCmd)
appsCmd.AddCommand(appsForegroundCmd)

appsLaunchCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to launch app on")
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")
appsUninstallCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to uninstall app from")
appsForegroundCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to get foreground app from")
}
3 changes: 3 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ APP MANAGEMENT:
# List installed apps
mobilecli apps list --device <device-id>

# Get currently foreground app
mobilecli apps foreground --device <device-id>

# Install an app (.apk for Android, .ipa/.zip for iOS)
mobilecli apps install --device <device-id> /path/to/app.apk

Expand Down
30 changes: 30 additions & 0 deletions commands/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package commands

import (
"fmt"

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

// AppRequest represents the parameters for app-related commands
Expand Down Expand Up @@ -72,6 +74,34 @@ func ListAppsCommand(req ListAppsRequest) *CommandResponse {
return NewSuccessResponse(apps)
}

// ForegroundAppRequest represents the parameters for getting the foreground app
type ForegroundAppRequest struct {
DeviceID string `json:"deviceId"`
}

// ForegroundAppCommand gets the currently foreground app on a device
func ForegroundAppCommand(req ForegroundAppRequest) *CommandResponse {
targetDevice, err := FindDeviceOrAutoSelect(req.DeviceID)
if err != nil {
return NewErrorResponse(fmt.Errorf("error finding device: %v", err))
}

// start agent if needed (for WDA)
err = targetDevice.StartAgent(devices.StartAgentConfig{
Hook: GetShutdownHook(),
})
if err != nil {
return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %v", targetDevice.ID(), err))
}

app, err := targetDevice.GetForegroundApp()
if err != nil {
return NewErrorResponse(fmt.Errorf("failed to get foreground app on device %s: %v", targetDevice.ID(), err))
}

return NewSuccessResponse(app)
}

type InstallAppRequest struct {
DeviceID string `json:"deviceId"`
Path string `json:"path"`
Expand Down
65 changes: 65 additions & 0 deletions devices/android.go
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,71 @@ func (d *AndroidDevice) ListApps() ([]InstalledAppInfo, error) {
return apps, nil
}

func (d *AndroidDevice) getForegroundPackageName() (string, error) {
output, err := d.runAdbCommand("shell", "dumpsys", "window", "displays")
if err != nil {
return "", fmt.Errorf("failed to get window displays: %w", err)
}

// parse package name from mCurrentFocus line
// format: mCurrentFocus=Window{...u0 com.package.name/...}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "mCurrentFocus") {
parts := strings.Fields(line)
if len(parts) >= 3 {
focusPart := parts[2]
// extract package name (before the '/')
if idx := strings.Index(focusPart, "/"); idx != -1 {
return focusPart[:idx], nil
}
}
break
}
}

return "", fmt.Errorf("could not determine foreground app")
}

func (d *AndroidDevice) getAppVersion(packageName string) (string, error) {
output, err := d.runAdbCommand("shell", "dumpsys", "package", packageName)
if err != nil {
return "", fmt.Errorf("failed to get package info: %w", err)
}

// parse version name
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "versionName=") {
parts := strings.Split(line, "versionName=")
if len(parts) >= 2 {
return strings.TrimSpace(parts[1]), nil
}
break
}
}

return "", nil
}

func (d *AndroidDevice) GetForegroundApp() (*ForegroundAppInfo, error) {
packageName, err := d.getForegroundPackageName()
if err != nil {
return nil, err
}

version, err := d.getAppVersion(packageName)
if err != nil {
return nil, err
}

return &ForegroundAppInfo{
PackageName: packageName,
AppName: packageName,
Version: version,
}, nil
}

func (d *AndroidDevice) Info() (*FullDeviceInfo, error) {

// run adb shell wm size
Expand Down
8 changes: 8 additions & 0 deletions devices/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type ControllableDevice interface {
TerminateApp(bundleID string) error
OpenURL(url string) error
ListApps() ([]InstalledAppInfo, error)
GetForegroundApp() (*ForegroundAppInfo, error)
InstallApp(path string) error
UninstallApp(packageName string) (*InstalledAppInfo, error)
Info() (*FullDeviceInfo, error)
Expand Down Expand Up @@ -230,3 +231,10 @@ type InstalledAppInfo struct {
AppName string `json:"appName,omitempty"`
Version string `json:"version,omitempty"`
}

// ForegroundAppInfo represents information about the currently foreground application
type ForegroundAppInfo struct {
PackageName string `json:"packageName"`
AppName string `json:"appName"`
Version string `json:"version"`
}
32 changes: 32 additions & 0 deletions devices/ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,38 @@ func (d *IOSDevice) ListApps() ([]InstalledAppInfo, error) {
return apps, nil
}

func (d *IOSDevice) GetForegroundApp() (*ForegroundAppInfo, error) {
// get active app info from WDA
activeApp, err := d.wdaClient.GetActiveAppInfo()
if err != nil {
return nil, fmt.Errorf("failed to get active app info: %w", err)
}

// get all installed apps to enrich with version information
apps, err := d.ListApps()
if err != nil {
return nil, fmt.Errorf("failed to list apps: %w", err)
}

// find the matching app to get full details
for _, app := range apps {
if app.PackageName == activeApp.BundleID {
return &ForegroundAppInfo{
PackageName: app.PackageName,
AppName: app.AppName,
Version: app.Version,
}, nil
}
}

// if app not found in list (e.g., system app), return info from WDA only
return &ForegroundAppInfo{
PackageName: activeApp.BundleID,
AppName: activeApp.Name,
Version: "",
}, nil
}

func (d IOSDevice) Info() (*FullDeviceInfo, error) {
wdaSize, err := d.wdaClient.GetWindowSize()
if err != nil {
Expand Down
32 changes: 32 additions & 0 deletions devices/simulator.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,38 @@ func (s *SimulatorDevice) ListApps() ([]InstalledAppInfo, error) {
return apps, nil
}

func (s *SimulatorDevice) GetForegroundApp() (*ForegroundAppInfo, error) {
// get active app info from WDA
activeApp, err := s.wdaClient.GetActiveAppInfo()
if err != nil {
return nil, fmt.Errorf("failed to get active app info: %w", err)
}

// get all installed apps to enrich with version information
apps, err := s.ListApps()
if err != nil {
return nil, fmt.Errorf("failed to list apps: %w", err)
}

// find the matching app to get full details
for _, app := range apps {
if app.PackageName == activeApp.BundleID {
return &ForegroundAppInfo{
PackageName: app.PackageName,
AppName: app.AppName,
Version: app.Version,
}, nil
}
}

// if app not found in list (e.g., system app), return info from WDA only
return &ForegroundAppInfo{
PackageName: activeApp.BundleID,
AppName: activeApp.Name,
Version: "",
}, nil
}

func (s *SimulatorDevice) Info() (*FullDeviceInfo, error) {
wdaSize, err := s.wdaClient.GetWindowSize()
if err != nil {
Expand Down
Loading
Loading