Skip to content
Closed
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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,29 @@ mobilecli io button --device <device-id> POWER
mobilecli io text --device <device-id> 'hello world'
```

### DeviceKit (iOS Real Devices) 📱

DeviceKit provides automation capabilities for iOS real devices, including tap/dumpUI commands and screen streaming.

```bash
# Start DeviceKit on an iOS device
mobilecli devicekit start --device <device-id>
```

The command will output the local ports for HTTP and streaming:
```json
{
"status": "ok",
"data": {
"httpPort": 12004,
"streamPort": 12100,
"message": "DeviceKit started on device ..."
}
}
```

The process keeps running until you press Ctrl+C. While running, you can use the HTTP endpoint for automation commands.

### Supported Hardware Buttons

- `HOME` - Home button
Expand All @@ -181,7 +204,8 @@ mobilecli io text --device <device-id> 'hello world'
## Platform-Specific Notes

### iOS Real Devices
- Currently requires that you install and run WebDriverAgent manually. You may change the BUNDLE IDENTIFIER, and *mobilecli* will be able to launch it if needed, as long as the identifier ends with `*.WebDriverAgent`.
- Use `mobilecli devicekit start` to enable automation on iOS real devices. This starts the DeviceKit XCUITest runner which provides tap, dumpUI, and screen streaming capabilities.
- Alternatively, you can install and run WebDriverAgent manually. You may change the BUNDLE IDENTIFIER, and *mobilecli* will be able to launch it if needed, as long as the identifier ends with `*.WebDriverAgent`.

## Development 👩‍💻

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

import (
"fmt"
"os"
"os/signal"
"syscall"

"github.com/mobile-next/mobilecli/commands"
"github.com/spf13/cobra"
)

var devicekitCmd = &cobra.Command{
Use: "devicekit",
Short: "Manage DeviceKit on iOS devices",
Long: `Start and control DeviceKit on iOS devices. DeviceKit provides tap/dumpUI commands and screen streaming.`,
}

var devicekitStartCmd = &cobra.Command{
Use: "start",
Short: "Start DeviceKit on an iOS device",
Long: `Starts the devicekit-ios XCUITest which provides:
- HTTP server for tap/dumpUI commands
- Broadcast extension for H.264 screen streaming

The command returns the local ports that are forwarded to the device.`,
RunE: func(cmd *cobra.Command, args []string) error {
req := commands.DeviceKitStartRequest{
DeviceID: deviceId,
}

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

// Keep the process running to maintain the XCUITest runner alive
fmt.Fprintln(os.Stderr, "DeviceKit is running. Press Ctrl+C to stop.")

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

<-sigChan

fmt.Fprintln(os.Stderr, "Shutting down DeviceKit...")
return nil
},
}

func init() {
rootCmd.AddCommand(devicekitCmd)

devicekitCmd.AddCommand(devicekitStartCmd)

devicekitStartCmd.Flags().StringVar(&deviceId, "device", "", "ID of the iOS device to start DeviceKit on")
}
45 changes: 45 additions & 0 deletions commands/devicekit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package commands

import (
"fmt"

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

// DeviceKitStartRequest represents the parameters for starting DeviceKit
type DeviceKitStartRequest struct {
DeviceID string `json:"deviceId"`
}

// DeviceKitStartResponse contains information about the started DeviceKit session
type DeviceKitStartResponse struct {
HTTPPort int `json:"httpPort"`
StreamPort int `json:"streamPort"`
Message string `json:"message"`
}

// DeviceKitStartCommand starts the devicekit-ios XCUITest which provides tap/dumpUI server and broadcast extension
func DeviceKitStartCommand(req DeviceKitStartRequest) *CommandResponse {
targetDevice, err := FindDeviceOrAutoSelect(req.DeviceID)
if err != nil {
return NewErrorResponse(fmt.Errorf("error finding device: %v", err))
}

// Check if this is an iOS device
iosDevice, ok := targetDevice.(*devices.IOSDevice)
if !ok {
return NewErrorResponse(fmt.Errorf("devicekit is only supported on iOS real devices, got %s %s", targetDevice.Platform(), targetDevice.DeviceType()))
}

// Start DeviceKit
info, err := iosDevice.StartDeviceKit()
if err != nil {
return NewErrorResponse(fmt.Errorf("failed to start devicekit: %v", err))
}

return NewSuccessResponse(DeviceKitStartResponse{
HTTPPort: info.HTTPPort,
StreamPort: info.StreamPort,
Message: fmt.Sprintf("DeviceKit started on device %s. HTTP server on localhost:%d, H.264 stream on localhost:%d", targetDevice.ID(), info.HTTPPort, info.StreamPort),
})
}
84 changes: 82 additions & 2 deletions devices/ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ func (d *IOSDevice) StartAgent(config StartAgentConfig) error {
}

// launch WebDriverAgent using testmanagerd
err = d.LaunchWda(webdriverBundleId, webdriverBundleId, "WebDriverAgentRunner.xctest")
err = d.LaunchTestRunner(webdriverBundleId, webdriverBundleId, "WebDriverAgentRunner.xctest")
if err != nil {
return fmt.Errorf("failed to launch WebDriverAgent: %w", err)
}
Expand Down Expand Up @@ -385,7 +385,7 @@ func (d *IOSDevice) StartAgent(config StartAgentConfig) error {
return nil
}

func (d IOSDevice) LaunchWda(bundleID, testRunnerBundleID, xctestConfig string) error {
func (d IOSDevice) LaunchTestRunner(bundleID, testRunnerBundleID, xctestConfig string) error {
if bundleID == "" && testRunnerBundleID == "" && xctestConfig == "" {
utils.Verbose("No bundle ids specified, falling back to defaults")
bundleID, testRunnerBundleID, xctestConfig = "com.facebook.WebDriverAgentRunner.xctrunner", "com.facebook.WebDriverAgentRunner.xctrunner", "WebDriverAgentRunner.xctest"
Expand Down Expand Up @@ -779,3 +779,83 @@ func (d IOSDevice) GetOrientation() (string, error) {
func (d IOSDevice) SetOrientation(orientation string) error {
return d.wdaClient.SetOrientation(orientation)
}

// DeviceKitInfo contains information about the started DeviceKit session
type DeviceKitInfo struct {
HTTPPort int `json:"httpPort"`
StreamPort int `json:"streamPort"`
}

// StartDeviceKit starts the devicekit-ios XCUITest which provides:
// - An HTTP server for tap/dumpUI commands (port 12004)
// - A broadcast extension for H.264 screen streaming (port 12005)
func (d *IOSDevice) StartDeviceKit() (*DeviceKitInfo, error) {
const (
httpPort = 12004 // XCUITest HTTP server for tap/dumpUI
streamPort = 12005 // H.264 TCP stream from broadcast extension
bundleID = "com.mobilenext.devicekit-ios"
testRunnerBundleID = "com.mobilenext.devicekit-iosUITests.xctrunner"
xctestConfig = "devicekit-iosUITests.xctest"
)

// Start tunnel if needed (iOS 17+)
err := d.startTunnel()
if err != nil {
return nil, fmt.Errorf("failed to start tunnel: %w", err)
}

// Find available local ports for forwarding
localHTTPPort, err := findAvailablePortInRange(12004, 12099)
if err != nil {
return nil, fmt.Errorf("failed to find available port for HTTP: %w", err)
}

localStreamPort, err := findAvailablePortInRange(12100, 12199)
if err != nil {
return nil, fmt.Errorf("failed to find available port for stream: %w", err)
}

// Set up port forwarding for HTTP server
httpForwarder := ios.NewPortForwarder(d.ID())
err = httpForwarder.Forward(localHTTPPort, httpPort)
if err != nil {
return nil, fmt.Errorf("failed to forward HTTP port: %w", err)
}
utils.Verbose("Port forwarding started: localhost:%d -> device:%d (HTTP)", localHTTPPort, httpPort)

// Set up port forwarding for H.264 stream
streamForwarder := ios.NewPortForwarder(d.ID())
err = streamForwarder.Forward(localStreamPort, streamPort)
if err != nil {
// Clean up HTTP forwarder on failure
_ = httpForwarder.Stop()
return nil, fmt.Errorf("failed to forward stream port: %w", err)
}
utils.Verbose("Port forwarding started: localhost:%d -> device:%d (H.264 stream)", localStreamPort, streamPort)

// Launch the XCUITest
err = d.LaunchTestRunner(bundleID, testRunnerBundleID, xctestConfig)
if err != nil {
// Clean up port forwarders on failure
_ = httpForwarder.Stop()
_ = streamForwarder.Stop()
return nil, fmt.Errorf("failed to launch XCUITest: %w", err)
}

utils.Verbose("DeviceKit started successfully")

return &DeviceKitInfo{
HTTPPort: localHTTPPort,
StreamPort: localStreamPort,
}, nil
}

// findAvailablePortInRange finds an available port in the specified range
func findAvailablePortInRange(start, end int) (int, error) {
for port := start; port <= end; port++ {
if utils.IsPortAvailable("localhost", port) {
return port, nil
}
}
return 0, fmt.Errorf("no available ports found in range %d-%d", start, end)
}
1 change: 1 addition & 0 deletions server/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func GetMethodRegistry() map[string]HandlerFunc {
"device_boot": handleDeviceBoot,
"device_shutdown": handleDeviceShutdown,
"device_reboot": handleDeviceReboot,
"devicekit_start": handleDeviceKitStart,
"dump_ui": handleDumpUI,
"apps_launch": handleAppsLaunch,
"apps_terminate": handleAppsTerminate,
Expand Down
24 changes: 24 additions & 0 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ type AppsListParams struct {
DeviceID string `json:"deviceId"`
}

type DeviceKitStartParams struct {
DeviceID string `json:"deviceId"`
}

func handleIoButton(params json.RawMessage) (interface{}, error) {
if len(params) == 0 {
return nil, fmt.Errorf("'params' is required with fields: deviceId, button")
Expand Down Expand Up @@ -759,6 +763,26 @@ func handleAppsList(params json.RawMessage) (interface{}, error) {
return response.Data, nil
}

func handleDeviceKitStart(params json.RawMessage) (interface{}, error) {
var devicekitParams DeviceKitStartParams
if len(params) > 0 {
if err := json.Unmarshal(params, &devicekitParams); err != nil {
return nil, fmt.Errorf("invalid parameters: %v. Expected fields: deviceId (optional)", err)
}
}

req := commands.DeviceKitStartRequest{
DeviceID: devicekitParams.DeviceID,
}

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

return response.Data, nil
}

func sendJSONRPCError(w http.ResponseWriter, id interface{}, code int, message string, data interface{}) {
response := JSONRPCResponse{
JSONRPC: "2.0",
Expand Down
Loading