Skip to content

Commit 6f6decc

Browse files
authored
feat: many devicekit-ios start optimizations (#162)
* feat: optimizing start devicekit, starting at 13 seconds * feat: check if devicekit-ios is already running and ready to stream video * fixed potential bug * revert page source with attributes
1 parent 21715b2 commit 6f6decc

File tree

2 files changed

+269
-11
lines changed

2 files changed

+269
-11
lines changed

devices/ios.go

Lines changed: 212 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -902,15 +902,57 @@ func (d *IOSDevice) StartScreenCapture(config ScreenCaptureConfig) error {
902902
// handle avc format via DeviceKit
903903
if config.Format == "avc" {
904904
if config.OnProgress != nil {
905-
config.OnProgress("Starting DeviceKit for H.264 streaming")
905+
config.OnProgress("Checking DeviceKit status")
906906
}
907907

908-
// start DeviceKit
909-
// Note: passing nil registry since this is internal call from StartScreenCapture
910-
// ScreenCapture callers should have already registered the device via StartAgent
911-
deviceKitInfo, err := d.StartDeviceKit(nil)
912-
if err != nil {
913-
return fmt.Errorf("failed to start DeviceKit: %w", err)
908+
var deviceKitInfo *DeviceKitInfo
909+
var err error
910+
911+
// check if DeviceKit is already running
912+
if d.isDeviceKitRunning() {
913+
utils.Verbose("DeviceKit already running, reusing existing session")
914+
915+
// check if we need to create port forwarders
916+
d.mu.Lock()
917+
hasHTTPForwarder := d.portForwarderDeviceKit != nil && d.portForwarderDeviceKit.IsRunning()
918+
hasStreamForwarder := d.portForwarderAvc != nil && d.portForwarderAvc.IsRunning()
919+
d.mu.Unlock()
920+
921+
if hasHTTPForwarder && hasStreamForwarder {
922+
// reuse existing forwarders
923+
d.mu.Lock()
924+
httpPort, _ := d.portForwarderDeviceKit.GetPorts()
925+
streamPort, _ := d.portForwarderAvc.GetPorts()
926+
d.mu.Unlock()
927+
928+
deviceKitInfo = &DeviceKitInfo{
929+
HTTPPort: httpPort,
930+
StreamPort: streamPort,
931+
}
932+
} else {
933+
// DeviceKit running but we need to create forwarders
934+
deviceKitInfo, err = d.ensureDeviceKitPortForwarders()
935+
if err != nil {
936+
return fmt.Errorf("failed to create port forwarders: %w", err)
937+
}
938+
}
939+
940+
if config.OnProgress != nil {
941+
config.OnProgress("Using existing DeviceKit session")
942+
}
943+
} else {
944+
// DeviceKit not running, start it normally
945+
if config.OnProgress != nil {
946+
config.OnProgress("Starting DeviceKit for H.264 streaming")
947+
}
948+
949+
// start DeviceKit
950+
// Note: passing nil registry since this is internal call from StartScreenCapture
951+
// ScreenCapture callers should have already registered the device via StartAgent
952+
deviceKitInfo, err = d.StartDeviceKit(nil)
953+
if err != nil {
954+
return fmt.Errorf("failed to start DeviceKit: %w", err)
955+
}
914956
}
915957

916958
if config.OnProgress != nil {
@@ -1228,6 +1270,134 @@ func filterButtons(elements []ScreenElement) []ScreenElement {
12281270
return buttons
12291271
}
12301272

1273+
func (d *IOSDevice) ensureDeviceKitPortForwarders() (*DeviceKitInfo, error) {
1274+
var httpPort, streamPort int
1275+
var err error
1276+
1277+
// check if HTTP forwarder exists, create if needed
1278+
d.mu.Lock()
1279+
hasHTTPForwarder := d.portForwarderDeviceKit != nil && d.portForwarderDeviceKit.IsRunning()
1280+
d.mu.Unlock()
1281+
1282+
if !hasHTTPForwarder {
1283+
httpPort, err = findAvailablePortInRange(portRangeStart, portRangeEnd)
1284+
if err != nil {
1285+
return nil, fmt.Errorf("failed to find available port for HTTP: %w", err)
1286+
}
1287+
1288+
forwarder := ios.NewPortForwarder(d.ID())
1289+
err = forwarder.Forward(httpPort, deviceKitHTTPPort)
1290+
if err != nil {
1291+
return nil, fmt.Errorf("failed to forward HTTP port: %w", err)
1292+
}
1293+
1294+
d.mu.Lock()
1295+
d.portForwarderDeviceKit = forwarder
1296+
d.mu.Unlock()
1297+
utils.Verbose("Port forwarding created: localhost:%d -> device:%d (HTTP)", httpPort, deviceKitHTTPPort)
1298+
} else {
1299+
d.mu.Lock()
1300+
httpPort, _ = d.portForwarderDeviceKit.GetPorts()
1301+
d.mu.Unlock()
1302+
}
1303+
1304+
// check if stream forwarder exists, create if needed
1305+
d.mu.Lock()
1306+
hasStreamForwarder := d.portForwarderAvc != nil && d.portForwarderAvc.IsRunning()
1307+
d.mu.Unlock()
1308+
1309+
if !hasStreamForwarder {
1310+
streamPort, err = findAvailablePortInRange(portRangeStart, portRangeEnd)
1311+
if err != nil {
1312+
if !hasHTTPForwarder {
1313+
_ = d.portForwarderDeviceKit.Stop()
1314+
}
1315+
return nil, fmt.Errorf("failed to find available port for stream: %w", err)
1316+
}
1317+
1318+
d.mu.Lock()
1319+
d.portForwarderAvc = ios.NewPortForwarder(d.ID())
1320+
d.mu.Unlock()
1321+
1322+
err = d.portForwarderAvc.Forward(streamPort, deviceKitStreamPort)
1323+
if err != nil {
1324+
if !hasHTTPForwarder {
1325+
_ = d.portForwarderDeviceKit.Stop()
1326+
}
1327+
return nil, fmt.Errorf("failed to forward stream port: %w", err)
1328+
}
1329+
utils.Verbose("Port forwarding created: localhost:%d -> device:%d (H.264 stream)", streamPort, deviceKitStreamPort)
1330+
} else {
1331+
d.mu.Lock()
1332+
streamPort, _ = d.portForwarderAvc.GetPorts()
1333+
d.mu.Unlock()
1334+
}
1335+
1336+
return &DeviceKitInfo{
1337+
HTTPPort: httpPort,
1338+
StreamPort: streamPort,
1339+
}, nil
1340+
}
1341+
1342+
func (d *IOSDevice) isDeviceKitRunning() bool {
1343+
// check if we already have port forwarders running
1344+
d.mu.Lock()
1345+
hasHTTPForwarder := d.portForwarderDeviceKit != nil && d.portForwarderDeviceKit.IsRunning()
1346+
hasStreamForwarder := d.portForwarderAvc != nil && d.portForwarderAvc.IsRunning()
1347+
d.mu.Unlock()
1348+
1349+
// if both forwarders exist, DeviceKit is definitely running from our perspective
1350+
if hasHTTPForwarder && hasStreamForwarder {
1351+
utils.Verbose("DeviceKit port forwarders already running")
1352+
return true
1353+
}
1354+
1355+
// find an available local port for testing
1356+
testPort, err := findAvailablePortInRange(portRangeStart, portRangeEnd)
1357+
if err != nil {
1358+
utils.Verbose("Could not find available port for DeviceKit check: %v", err)
1359+
return false
1360+
}
1361+
1362+
// create temporary port forwarder to device port 12005 (stream)
1363+
testForwarder := ios.NewPortForwarder(d.ID())
1364+
err = testForwarder.Forward(testPort, deviceKitStreamPort)
1365+
if err != nil {
1366+
utils.Verbose("Could not create test port forwarder: %v", err)
1367+
return false
1368+
}
1369+
1370+
// ensure cleanup of test forwarder
1371+
defer func() {
1372+
_ = testForwarder.Stop()
1373+
}()
1374+
1375+
// try to connect with timeout
1376+
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", testPort), 2*time.Second)
1377+
if err != nil {
1378+
utils.Verbose("DeviceKit not responding on port %d: %v", deviceKitStreamPort, err)
1379+
return false
1380+
}
1381+
defer func() { _ = conn.Close() }()
1382+
1383+
// set read deadline and try to read 1 byte
1384+
err = conn.SetReadDeadline(time.Now().Add(1 * time.Second))
1385+
if err != nil {
1386+
utils.Verbose("Could not set read deadline: %v", err)
1387+
return false
1388+
}
1389+
1390+
buffer := make([]byte, 1)
1391+
_, err = conn.Read(buffer)
1392+
if err != nil {
1393+
utils.Verbose("DeviceKit not serving data on port %d: %v", deviceKitStreamPort, err)
1394+
return false
1395+
}
1396+
1397+
utils.Verbose("DeviceKit is already running on device port %d", deviceKitStreamPort)
1398+
return true
1399+
}
1400+
12311401
// StartDeviceKit starts the devicekit-ios XCUITest which provides:
12321402
// - An HTTP server for tap/dumpUI commands (port 12004)
12331403
// - A broadcast extension for H.264 screen streaming (port 12005)
@@ -1312,16 +1482,23 @@ func (d *IOSDevice) StartDeviceKit(hook *ShutdownHook) (*DeviceKitInfo, error) {
13121482
return nil, fmt.Errorf("failed to launch DeviceKit app: %w", err)
13131483
}
13141484

1315-
// Wait for the app to launch and show the broadcast picker
1316-
utils.Verbose("Waiting %v for DeviceKit app to launch...", deviceKitAppLaunchTimeout)
1317-
time.Sleep(deviceKitAppLaunchTimeout)
1485+
// wait for the app to be in foreground
1486+
utils.Verbose("Waiting for DeviceKit app to be in foreground...")
1487+
err = d.waitForAppInForeground(devicekitMainAppBundleId, deviceKitAppLaunchTimeout)
1488+
if err != nil {
1489+
// clean up port forwarders on failure
1490+
_ = d.portForwarderDeviceKit.Stop()
1491+
_ = d.portForwarderAvc.Stop()
1492+
return nil, fmt.Errorf("failed to wait for DeviceKit app: %w", err)
1493+
}
13181494

13191495
// Start WebDriverAgent to be able to tap on the screen
13201496
err = d.StartAgent(StartAgentConfig{
13211497
OnProgress: func(message string) {
13221498
utils.Verbose(message)
13231499
},
13241500
})
1501+
13251502
if err != nil {
13261503
// clean up port forwarders on failure
13271504
_ = d.portForwarderDeviceKit.Stop()
@@ -1354,6 +1531,31 @@ func (d *IOSDevice) StartDeviceKit(hook *ShutdownHook) (*DeviceKitInfo, error) {
13541531
}, nil
13551532
}
13561533

1534+
// waitForAppInForeground polls WDA to check if the specified app is in foreground
1535+
func (d *IOSDevice) waitForAppInForeground(bundleID string, timeout time.Duration) error {
1536+
deadline := time.After(timeout)
1537+
ticker := time.NewTicker(200 * time.Millisecond)
1538+
defer ticker.Stop()
1539+
1540+
for {
1541+
select {
1542+
case <-deadline:
1543+
return fmt.Errorf("timeout waiting for app %s to be in foreground", bundleID)
1544+
case <-ticker.C:
1545+
activeApp, err := d.wdaClient.GetActiveAppInfo()
1546+
if err != nil {
1547+
// continue trying on error
1548+
continue
1549+
}
1550+
1551+
if activeApp.BundleID == bundleID {
1552+
utils.Verbose("App %s is now in foreground", bundleID)
1553+
return nil
1554+
}
1555+
}
1556+
}
1557+
}
1558+
13571559
// findAvailablePortInRange finds an available port in the specified range
13581560
func findAvailablePortInRange(start, end int) (int, error) {
13591561
for port := start; port <= end; port++ {

devices/wda/source.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package wda
33
import (
44
"encoding/json"
55
"fmt"
6+
"strings"
67
"time"
78

89
"github.com/mobile-next/mobilecli/types"
@@ -92,7 +93,8 @@ func (c *WdaClient) GetSource() (map[string]interface{}, error) {
9293

9394
// GetSourceRaw gets the raw page source from WDA's /source endpoint
9495
func (c *WdaClient) GetSourceRaw() (interface{}, error) {
95-
endpoint := "source?format=json"
96+
startTime := time.Now()
97+
endpoint := "source?format=json&excluded_attributes="
9698

9799
result, err := c.getEndpointWithTimeout(endpoint, 60*time.Second)
98100
if err != nil {
@@ -104,11 +106,65 @@ func (c *WdaClient) GetSourceRaw() (interface{}, error) {
104106
return nil, fmt.Errorf("no 'value' field found in WDA response")
105107
}
106108

109+
elapsed := time.Since(startTime)
110+
utils.Verbose("GetSourceRaw took %.2f seconds", elapsed.Seconds())
111+
112+
return value, nil
113+
}
114+
115+
// GetSourceRawWithAttributes gets the raw page source with only the specified attributes included
116+
func (c *WdaClient) GetSourceRawWithAttributes(attributes []string) (interface{}, error) {
117+
startTime := time.Now()
118+
119+
// all possible attributes that can be excluded
120+
allAttributes := []string{
121+
"type", "value", "name", "label", "enabled", "visible", "accessible", "focused",
122+
"x", "y", "width", "height", "index", "hittable", "bundleId", "processId",
123+
"placeholderValue", "nativeFrame", "traits", "minValue", "maxValue", "customActions",
124+
}
125+
126+
// build excluded list by removing requested attributes from all attributes
127+
excludedAttrs := []string{}
128+
for _, attr := range allAttributes {
129+
include := false
130+
for _, requestedAttr := range attributes {
131+
if attr == requestedAttr {
132+
include = true
133+
break
134+
}
135+
}
136+
if !include {
137+
excludedAttrs = append(excludedAttrs, attr)
138+
}
139+
}
140+
141+
excludedStr := ""
142+
if len(excludedAttrs) > 0 {
143+
excludedStr = fmt.Sprintf("&excluded_attributes=%s", strings.Join(excludedAttrs, ","))
144+
}
145+
146+
endpoint := fmt.Sprintf("source?format=json%s", excludedStr)
147+
148+
result, err := c.getEndpointWithTimeout(endpoint, 60*time.Second)
149+
if err != nil {
150+
return nil, fmt.Errorf("failed to get source: %w", err)
151+
}
152+
153+
value, ok := result["value"]
154+
if !ok {
155+
return nil, fmt.Errorf("no 'value' field found in WDA response")
156+
}
157+
158+
elapsed := time.Since(startTime)
159+
utils.Verbose("GetSourceRawWithAttributes took %.2f seconds (attributes: %v)", elapsed.Seconds(), attributes)
160+
107161
return value, nil
108162
}
109163

110164
func (c *WdaClient) GetSourceElements() ([]types.ScreenElement, error) {
111165
value, err := c.GetSourceRaw()
166+
// only fetch the attributes we actually use
167+
// value, err := c.GetSourceRawWithAttributes([]string{"type", "name", "label", "value", "visible", "x", "y", "width", "height"})
112168
if err != nil {
113169
return nil, err
114170
}

0 commit comments

Comments
 (0)