Skip to content
21 changes: 14 additions & 7 deletions commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,22 @@ func FindDeviceOrAutoSelect(deviceID string) (devices.ControllableDevice, error)
return nil, fmt.Errorf("no online devices found")
}

if len(onlineDevices) == 1 {
device := onlineDevices[0]
// Cache the device for future use
deviceCache[device.ID()] = device
return device, nil
if len(onlineDevices) > 1 {
err = fmt.Errorf("multiple devices found (%d), please specify --device with one of: %s", len(onlineDevices), getDeviceIDList(onlineDevices))
return nil, err
}

// exactly 1 online device - check cache first to reuse existing instance
deviceID = onlineDevices[0].ID()
cachedDevice, exists := deviceCache[deviceID]
if exists {
return cachedDevice, nil
}

err = fmt.Errorf("multiple devices found (%d), please specify --device with one of: %s", len(onlineDevices), getDeviceIDList(onlineDevices))
return nil, err
// not in cache, use the new device instance and cache it
device := onlineDevices[0]
deviceCache[device.ID()] = device
return device, nil
}

// getDeviceIDList returns a comma-separated list of device IDs for error messages
Expand Down
74 changes: 61 additions & 13 deletions devices/ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package devices

import (
"context"
"errors"
"fmt"
"io"
"strconv"
Expand Down Expand Up @@ -113,6 +114,12 @@ func (d IOSDevice) TakeScreenshot() ([]byte, error) {
func (d IOSDevice) Reboot() error {
log.SetLevel(log.WarnLevel)

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

device, err := d.getEnhancedDevice()
if err != nil {
return fmt.Errorf("failed to get enhanced device connection: %w", err)
Expand Down Expand Up @@ -214,29 +221,46 @@ func (d *IOSDevice) requiresTunnel() bool {
return majorVersion >= 17
}

func (d *IOSDevice) waitForTunnelReady() error {
tunnelMgr := d.tunnelManager.GetTunnelManager()
timeout := time.After(10 * time.Second)
ticker := time.NewTicker(150 * time.Millisecond)
defer ticker.Stop()

for {
select {
case <-timeout:
return fmt.Errorf("timeout waiting for tunnel to be ready for device %s", d.Udid)
case <-ticker.C:
tunnelInfo, err := tunnelMgr.FindTunnel(d.Udid)
if err == nil && tunnelInfo.Udid != "" {
utils.Verbose("Tunnel ready for device %s", d.Udid)
return nil
}
}
}
}

func (d *IOSDevice) startTunnel() error {
if !d.requiresTunnel() {
return nil
}

tunnels, err := d.ListTunnels()
// start tunnel if not already running
// TunnelManager.StartTunnel() will return error if already running
err := d.tunnelManager.StartTunnel()
if err != nil {
return fmt.Errorf("failed to list tunnels: %w", err)
}

if len(tunnels) > 0 {
utils.Verbose("Tunnels available for this device: %v", tunnels)
return nil
}
// check if it's the "already running" error, which is fine

utils.Verbose("No tunnels found, starting a new tunnel")
err = d.tunnelManager.StartTunnel()
if err != nil {
if errors.Is(err, ios.ErrTunnelAlreadyRunning) {
utils.Verbose("Tunnel already running for this device")
return nil
}
return fmt.Errorf("failed to start tunnel: %w", err)
}

time.Sleep(1 * time.Second)
return nil
utils.Verbose("Started new tunnel for device %s", d.Udid)
return d.waitForTunnelReady()
}

func (d *IOSDevice) StartAgent(config StartAgentConfig) error {
Expand Down Expand Up @@ -369,6 +393,12 @@ func (d IOSDevice) LaunchWda(bundleID, testRunnerBundleID, xctestConfig string)

utils.Verbose("Running wda with bundleid: %s, testbundleid: %s, xctestconfig: %s", bundleID, testRunnerBundleID, xctestConfig)

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

device, err := d.getEnhancedDevice()
if err != nil {
return fmt.Errorf("failed to get enhanced device connection: %w", err)
Expand Down Expand Up @@ -588,6 +618,12 @@ func (d IOSDevice) OpenURL(url string) error {
func (d IOSDevice) ListApps() ([]InstalledAppInfo, error) {
log.SetLevel(log.WarnLevel)

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

device, err := d.getEnhancedDevice()
if err != nil {
return nil, fmt.Errorf("failed to get enhanced device connection: %w", err)
Expand Down Expand Up @@ -677,6 +713,12 @@ func (d IOSDevice) DumpSourceRaw() (interface{}, error) {
func (d IOSDevice) InstallApp(path string) error {
log.SetLevel(log.WarnLevel)

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

device, err := d.getEnhancedDevice()
if err != nil {
return fmt.Errorf("failed to get enhanced device connection: %w", err)
Expand All @@ -699,6 +741,12 @@ func (d IOSDevice) InstallApp(path string) error {
func (d IOSDevice) UninstallApp(packageName string) (*InstalledAppInfo, error) {
log.SetLevel(log.WarnLevel)

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

device, err := d.getEnhancedDevice()
if err != nil {
return nil, fmt.Errorf("failed to get enhanced device connection: %w", err)
Expand Down
7 changes: 5 additions & 2 deletions devices/ios/tunnel-manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ios

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -13,6 +14,8 @@ import (
log "github.com/sirupsen/logrus"
)

var ErrTunnelAlreadyRunning = errors.New("tunnel is already running")

type TunnelManager struct {
udid string
tunnelMgr *tunnel.TunnelManager
Expand All @@ -32,7 +35,7 @@ func NewTunnelManager(udid string) (*TunnelManager, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("failed to create pair records directory: %w", err)
}

pm, err := tunnel.NewPairRecordManager(dir)
if err != nil {
return nil, fmt.Errorf("failed to create pair record manager: %w", err)
Expand All @@ -56,7 +59,7 @@ func (tm *TunnelManager) StartTunnelWithCallback(onProcessDied func(error)) erro
defer tm.tunnelMutex.Unlock()

if tm.updateCtx != nil {
return fmt.Errorf("tunnel is already running")
return ErrTunnelAlreadyRunning
}

ctx, cancel := context.WithCancel(context.Background())
Expand Down
27 changes: 27 additions & 0 deletions docs/jsonrpc_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@ This section documents the JSON-RPC methods registered by the server and shows e
- `deviceId` (string)
- `bundleId` (string) - required

- `apps_terminate`
- Description: Terminate a running app on a device.
- Params: object
- `deviceId` (string)
- `bundleId` (string) - required

- `apps_list`
- Description: List installed apps on a device.
- Params: object (optional)
- `deviceId` (string)

Common notes:
- For most methods `deviceId` is optional; when omitted the server auto-selects a single online device or returns an error when multiple devices are available.
- Methods that interact with the UI/agent (`io_*`, `dump_ui`, `apps_launch`, `device_info`, etc.) call `StartAgent` which may start/forward WDA for iOS devices. If WDA is unresponsive the server will attempt to relaunch it.
Expand Down Expand Up @@ -245,3 +256,19 @@ curl -s -X POST http://localhost:12000/rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"apps_launch","params":{"deviceId":"<id>","bundleId":"com.example.app"},"id":20}'
```

- Terminate app:

```bash
curl -s -X POST http://localhost:12000/rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"apps_terminate","params":{"deviceId":"<id>","bundleId":"com.example.app"},"id":21}'
```

- List apps:

```bash
curl -s -X POST http://localhost:12000/rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"apps_list","params":{"deviceId":"<id>"},"id":22}'
```
56 changes: 56 additions & 0 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ func handleJSONRPC(w http.ResponseWriter, r *http.Request) {
result, err = handleDumpUI(req.Params)
case "apps_launch":
result, err = handleAppsLaunch(req.Params)
case "apps_terminate":
result, err = handleAppsTerminate(req.Params)
case "apps_list":
result, err = handleAppsList(req.Params)
case "":
err = fmt.Errorf("'method' is required")

Expand Down Expand Up @@ -469,6 +473,15 @@ type AppsLaunchParams struct {
BundleID string `json:"bundleId"`
}

type AppsTerminateParams struct {
DeviceID string `json:"deviceId"`
BundleID string `json:"bundleId"`
}

type AppsListParams 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 @@ -726,6 +739,49 @@ func handleAppsLaunch(params json.RawMessage) (interface{}, error) {
return response.Data, nil
}

func handleAppsTerminate(params json.RawMessage) (interface{}, error) {
if len(params) == 0 {
return nil, fmt.Errorf("'params' is required with fields: deviceId, bundleId")
}

var appsTerminateParams AppsTerminateParams
if err := json.Unmarshal(params, &appsTerminateParams); err != nil {
return nil, fmt.Errorf("invalid parameters: %v. Expected fields: deviceId, bundleId", err)
}

req := commands.AppRequest{
DeviceID: appsTerminateParams.DeviceID,
BundleID: appsTerminateParams.BundleID,
}

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

return response.Data, nil
}

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

req := commands.ListAppsRequest{
DeviceID: appsListParams.DeviceID,
}

response := commands.ListAppsCommand(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