diff --git a/README.md b/README.md index cfa1a31..adf1607 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,23 @@ The CLI and daemon talk JSON-RPC over a unix domain socket in `~/.mobilecli/` (o On Android, the daemon keeps a small server running on the device (`com.mobilenext.mobilecli.DeviceServer`) that holds the device's only UiAutomation connection, so other UiAutomator clients such as `uiautomator dump` or Appium cannot run alongside it. The daemon stops that server when it exits, so `mobilecli daemon stop` hands the device back to other tools; the server also exits by itself after 30 minutes without requests (`MOBILECLI_DEVICE_SERVER_IDLE_TIMEOUT=5m` shortens that). +On a real iOS device, the daemon launches the on-device agent itself and reaches it through a tunnel (iOS 17+) and a port forward. If the agent is already running and reachable another way, set `MOBILECLI_IOS_AGENT_URL` to its URL and the daemon uses that agent as is, without launching it or starting a tunnel or forward for it. Commands that do not go through the agent (apps, logs, crashes, AVC streaming) are unchanged. + +The variable takes either a bare URL, which mobilecli accepts only while exactly one real iOS device is connected, or a comma-separated list of `=` pairs such as `00008101-00161CEC3CDB001E=http://[fd34:13d9:5468::1]:12004,00008110-001A2B3C4D5E001E=http://localhost:8100`. With a list, each device uses only its own entry and a device without one is reached the usual way. The agent cannot report which device it runs on, so the mapping is what binds a device to its agent. The URL cannot have a query, a fragment, or a user name and password. The agent serves plain HTTP with no authentication, so point the variable at loopback (a USB forward) or at the CoreDevice tunnel address below, not at the device's local network address. The daemon reads the variable when it starts, so run `mobilecli daemon stop` after changing it. + +For a device paired over Wi-Fi, the agent can listen on the Xcode CoreDevice tunnel address (devicekit-ios `DEVICEKIT_LISTEN_HOST`), which only the paired Mac can reach: + +```bash +UDID= +TUNNEL=$(xcrun devicectl device info details --device $UDID --json-output - | jq -r '.result.connectionProperties.tunnelIPAddress') +# in the devicekit-ios checkout, after building the runner for the device once +TEST_RUNNER_DEVICEKIT_LISTEN_HOST="127.0.0.1,$TUNNEL" xcodebuild test-without-building \ + -project devicekit-ios.xcodeproj -scheme devicekit-ios -destination "id=$UDID" -collect-test-diagnostics never & +MOBILECLI_IOS_AGENT_URL="$UDID=http://[$TUNNEL]:12004" mobilecli --device $UDID device info +``` + +The tunnel address can change when the tunnel reconnects, so read it right before launching the runner. With USB, the same variable works with a forward instead, for example `iproxy 8100 12004` and `MOBILECLI_IOS_AGENT_URL=http://localhost:8100`. + ## HTTP API 🔌 ***mobilecli*** provides an http interface for all the functionality that is available through command line. The server is a thin front for the daemon above, so HTTP clients and the CLI share one set of devices and tunnels. diff --git a/devices/common.go b/devices/common.go index b59026d..d9eb502 100644 --- a/devices/common.go +++ b/devices/common.go @@ -82,7 +82,12 @@ const ( ) func buildMjpegURL(port, fps int, scale float64) string { - url := fmt.Sprintf("http://localhost:%d/mjpeg", port) + return buildMjpegURLForAgent(fmt.Sprintf("http://localhost:%d", port), fps, scale) +} + +// buildMjpegURLForAgent builds the mjpeg URL served by the agent at baseURL. +func buildMjpegURLForAgent(baseURL string, fps int, scale float64) string { + url := baseURL + "/mjpeg" sep := "?" if fps > 0 { url += fmt.Sprintf("%sfps=%d", sep, fps) diff --git a/devices/devicekit/types.go b/devices/devicekit/types.go index c10fba8..9d4cdfc 100644 --- a/devices/devicekit/types.go +++ b/devices/devicekit/types.go @@ -31,6 +31,11 @@ func NewDeviceKitClient(hostPort string) *DeviceKitClient { } } +// BaseURL returns the agent URL this client talks to, without a trailing slash. +func (c *DeviceKitClient) BaseURL() string { + return c.baseURL +} + // Port returns the port this client talks to, parsed from its base URL. func (c *DeviceKitClient) Port() int { parsed, err := url.Parse(c.baseURL) diff --git a/devices/ios.go b/devices/ios.go index 4b88eae..f4b26c5 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -500,6 +500,14 @@ func (d *IOSDevice) StartAgent(config StartAgentConfig) error { // 6. we need to wait for the agent to be ready ✅ // 7. just in case, click HOME button ✅ + agentURL, err := iosAgentURLOverride(d.Udid) + if err != nil { + return err + } + if agentURL != "" { + return d.useExternalAgent(agentURL) + } + if _, err := d.deviceKitClient.GetStatus(); err == nil { return nil } @@ -1149,8 +1157,7 @@ func (d *IOSDevice) StartScreenCapture(config ScreenCaptureConfig) error { // mjpeg is served on the same port as the agent HTTP server at /mjpeg d.mu.Lock() - wdaPort := d.deviceKitClient.Port() - mjpegURL := buildMjpegURL(wdaPort, config.FPS, config.Scale) + mjpegURL := buildMjpegURLForAgent(d.deviceKitClient.BaseURL(), config.FPS, config.Scale) d.mjpegClient = mjpeg.NewDeviceKitMjpegClient(mjpegURL) d.mu.Unlock() diff --git a/devices/ios_agent_url.go b/devices/ios_agent_url.go new file mode 100644 index 0000000..d957183 --- /dev/null +++ b/devices/ios_agent_url.go @@ -0,0 +1,149 @@ +package devices + +import ( + "fmt" + "net/url" + "os" + "strings" + + goios "github.com/danielpaulus/go-ios/ios" + "github.com/mobile-next/mobilecli/devices/devicekit" +) + +// iosAgentURLEnv points real iOS devices at a DeviceKit agent that is already +// running and reachable at that URL (e.g. "http://localhost:8100", or the Xcode +// CoreDevice tunnel address of a Wi-Fi paired device such as +// "http://[fd34:13d9:5468::1]:12004"). When set, mobilecli neither launches the +// agent nor starts a tunnel or port forward to reach it. Calls that do not go +// through the agent (apps, logs, crashes) are unchanged. +// +// The value is either a comma-separated list of = pairs, where each +// device uses only its own entry and a device without one takes the normal +// tunnel path, or a bare URL, which is accepted only while exactly one real iOS +// device is connected. The agent cannot confirm which device it runs on +// (device.info returns only the screen size and scale), so the device is bound +// to the agent by this mapping alone. +const iosAgentURLEnv = "MOBILECLI_IOS_AGENT_URL" + +// countConnectedIOSDevices returns how many distinct real iOS devices usbmuxd +// reports. It only asks usbmuxd for its list, without opening a lockdown +// session per device as ListIOSDevices does. Tests replace it. +var countConnectedIOSDevices = func() (int, error) { + deviceList, err := goios.ListDevices() + if err != nil { + return 0, fmt.Errorf("failed getting device list: %w", err) + } + + // go-ios returns one entry per connection (usb, network), count by udid + seen := make(map[string]bool) + for _, deviceEntry := range deviceList.DeviceList { + seen[deviceEntry.Properties.SerialNumber] = true + } + return len(seen), nil +} + +// iosAgentURLOverride returns the agent URL that iosAgentURLEnv sets for the +// device with this udid, or "" when the device should take the normal path. +func iosAgentURLOverride(udid string) (string, error) { + value := strings.TrimSpace(os.Getenv(iosAgentURLEnv)) + if value == "" { + return "", nil + } + + // a bare URL may have '=' or ',' in its path, so classify it before + // treating those characters as mapping separators + if isBareAgentURL(value) || !strings.ContainsAny(value, "=,") { + return bareAgentURL(value) + } + + agentURLs, err := parseAgentURLMapping(value) + if err != nil { + return "", err + } + return agentURLs[strings.ToUpper(udid)], nil +} + +// isBareAgentURL reports whether value parses as a single http(s) URL with a +// host. A = mapping never does: '=' cannot appear in a URL scheme. +func isBareAgentURL(value string) bool { + parsed, err := url.Parse(value) + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" +} + +// bareAgentURL accepts a URL without a udid only when it cannot reach the +// wrong device, that is with a single real iOS device connected. +func bareAgentURL(value string) (string, error) { + agentURL, err := parseAgentURL(value) + if err != nil { + return "", err + } + + count, err := countConnectedIOSDevices() + if err != nil { + return "", err + } + if count != 1 { + return "", fmt.Errorf("%s holds a single URL, which needs exactly one connected iOS device but found %d, use = to say which device runs the agent", iosAgentURLEnv, count) + } + + return agentURL, nil +} + +// parseAgentURLMapping parses "=,=" into URLs keyed by +// upper-case udid. +func parseAgentURLMapping(value string) (map[string]string, error) { + agentURLs := make(map[string]string) + for _, entry := range strings.Split(value, ",") { + udid, rawURL, found := strings.Cut(strings.TrimSpace(entry), "=") + udid = strings.TrimSpace(udid) + rawURL = strings.TrimSpace(rawURL) + if !found || udid == "" || rawURL == "" || strings.ContainsAny(udid, ":/") { + return nil, fmt.Errorf("%s has a malformed entry %q, expected = such as 00008101-00161CEC3CDB001E=http://localhost:8100 (agent URLs cannot have a query)", iosAgentURLEnv, entry) + } + + key := strings.ToUpper(udid) + if _, duplicate := agentURLs[key]; duplicate { + return nil, fmt.Errorf("%s lists device %s more than once", iosAgentURLEnv, udid) + } + + agentURL, err := parseAgentURL(rawURL) + if err != nil { + return nil, err + } + agentURLs[key] = agentURL + } + return agentURLs, nil +} + +// parseAgentURL checks that value can serve as the base of agent requests, +// which append /health, /rpc and /mjpeg to it. +func parseAgentURL(value string) (string, error) { + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", fmt.Errorf("%s=%q is not a valid agent URL, expected something like http://localhost:8100", iosAgentURLEnv, value) + } + if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(value, "#") { + return "", fmt.Errorf("%s=%q is not a valid agent URL, it cannot have a query or fragment", iosAgentURLEnv, value) + } + // the agent has no authentication, so credentials would only travel in the clear + if parsed.User != nil { + return "", fmt.Errorf("%s=%q is not a valid agent URL, it cannot carry a user name or password", iosAgentURLEnv, value) + } + + return strings.TrimSuffix(value, "/"), nil +} + +// useExternalAgent switches the device to the agent at agentURL and checks that +// it answers. It never launches the agent or sets up a tunnel or forward. +func (d *IOSDevice) useExternalAgent(agentURL string) error { + client := devicekit.NewDeviceKitClient(agentURL) + if _, err := client.GetStatus(); err != nil { + return fmt.Errorf("no agent answering at %s (from %s): %w", agentURL, iosAgentURLEnv, err) + } + + d.mu.Lock() + d.deviceKitClient = client + d.mu.Unlock() + + return nil +} diff --git a/devices/ios_agent_url_test.go b/devices/ios_agent_url_test.go new file mode 100644 index 0000000..871a926 --- /dev/null +++ b/devices/ios_agent_url_test.go @@ -0,0 +1,243 @@ +package devices + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mobile-next/mobilecli/devices/devicekit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeAgentHandler answers /health and device.info the way devicekit-ios does. +func fakeAgentHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/rpc", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"scale":3,"screenSize":{"width":393,"height":852}}}`)) + }) + return mux +} + +func fakeAgent(t *testing.T) *httptest.Server { + agent := httptest.NewServer(fakeAgentHandler()) + t.Cleanup(agent.Close) + return agent +} + +// an iOS 17 device with no tunnel manager: any attempt to start a tunnel, +// forward a port or list apps would fail instead of reaching the fake agent. +func deviceWithoutTunnel(agentURL string) *IOSDevice { + return deviceWithUdid("00008110-000000000000001E", agentURL) +} + +func deviceWithUdid(udid, agentURL string) *IOSDevice { + return &IOSDevice{ + Udid: udid, + OSVersion: "17.5", + deviceKitClient: devicekit.NewDeviceKitClient(agentURL), + } +} + +// withConnectedIOSDevices makes the bare URL check see count devices instead of +// asking usbmuxd. +func withConnectedIOSDevices(t *testing.T, count int) { + original := countConnectedIOSDevices + countConnectedIOSDevices = func() (int, error) { return count, nil } + t.Cleanup(func() { countConnectedIOSDevices = original }) +} + +func TestStartAgentUsesTheAgentURLFromTheEnvironment(t *testing.T) { + withConnectedIOSDevices(t, 1) + agent := fakeAgent(t) + t.Setenv(iosAgentURLEnv, agent.URL+"/") + + // the default client points somewhere nothing listens + device := deviceWithoutTunnel("http://127.0.0.1:1") + + require.NoError(t, device.StartAgent(StartAgentConfig{})) + assert.Equal(t, agent.URL, device.deviceKitClient.BaseURL()) + + info, err := device.Info() + require.NoError(t, err) + assert.Equal(t, 393, info.ScreenSize.Width) + assert.Equal(t, 852, info.ScreenSize.Height) +} + +// the Xcode CoreDevice tunnel address is IPv6, so the URL carries a bracketed literal +func TestStartAgentAcceptsABracketedIPv6AgentURL(t *testing.T) { + listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp6", "[::1]:0") + if err != nil { + t.Skipf("no IPv6 loopback: %v", err) + } + agent := httptest.NewUnstartedServer(fakeAgentHandler()) + agent.Listener = listener + agent.Start() + t.Cleanup(agent.Close) + require.Contains(t, agent.URL, "[::1]") + withConnectedIOSDevices(t, 1) + t.Setenv(iosAgentURLEnv, agent.URL) + + device := deviceWithoutTunnel("http://127.0.0.1:1") + + require.NoError(t, device.StartAgent(StartAgentConfig{})) + info, err := device.Info() + require.NoError(t, err) + assert.Equal(t, 393, info.ScreenSize.Width) +} + +func TestStartAgentReportsAnAgentURLNothingAnswers(t *testing.T) { + withConnectedIOSDevices(t, 1) + t.Setenv(iosAgentURLEnv, "http://127.0.0.1:1") + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no agent answering at http://127.0.0.1:1") + assert.Contains(t, err.Error(), iosAgentURLEnv) +} + +func TestStartAgentRejectsAnInvalidAgentURL(t *testing.T) { + withConnectedIOSDevices(t, 1) + for _, value := range []string{"localhost:8100", "ftp://localhost:8100", "http://"} { + t.Setenv(iosAgentURLEnv, value) + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err, value) + assert.Contains(t, err.Error(), "is not a valid agent URL", value) + } +} + +// /health and /rpc are appended to the agent URL, so a query or fragment would +// swallow them, and the agent has no authentication to send credentials to. +func TestStartAgentRejectsAQueryFragmentOrUserInfo(t *testing.T) { + withConnectedIOSDevices(t, 1) + cases := map[string]string{ + "http://localhost:8100?x": "cannot have a query or fragment", + "http://localhost:8100/?": "cannot have a query or fragment", + "http://localhost:8100/#": "cannot have a query or fragment", + "http://localhost:8100/#health": "cannot have a query or fragment", + "http://localhost:8100?a=b": "cannot have a query or fragment", + "http://user:secret@192.168.1.20:8100": "cannot carry a user name or password", + "https://user@localhost:8100": "cannot carry a user name or password", + "00008110-000000000000001E=http://u:p@h:81": "cannot carry a user name or password", + "00008110-000000000000001E=http://h:81/#x": "cannot have a query or fragment", + } + for value, message := range cases { + t.Setenv(iosAgentURLEnv, value) + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err, value) + assert.Contains(t, err.Error(), message, value) + assert.Contains(t, err.Error(), iosAgentURLEnv, value) + } +} + +func TestStartAgentRejectsMalformedMappings(t *testing.T) { + for _, value := range []string{ + "00008110-000000000000001E=", + "=http://localhost:8100", + "00008110-000000000000001E=http://localhost:8100,", + "00008110-000000000000001E http://localhost:8100,00008101-00161CEC3CDB001E=http://localhost:8101", + } { + t.Setenv(iosAgentURLEnv, value) + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err, value) + assert.Contains(t, err.Error(), "expected =", value) + assert.Contains(t, err.Error(), iosAgentURLEnv, value) + } +} + +func TestStartAgentRejectsADeviceListedTwice(t *testing.T) { + t.Setenv(iosAgentURLEnv, "00008110-000000000000001E=http://localhost:8100,00008110-000000000000001e=http://localhost:8101") + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "more than once") +} + +func TestStartAgentPicksTheDevicesOwnEntry(t *testing.T) { + // the mapping is used as is, without asking usbmuxd how many devices there are + withConnectedIOSDevices(t, 5) + mine := fakeAgent(t) + other := fakeAgent(t) + t.Setenv(iosAgentURLEnv, "00008101-00161CEC3CDB001E="+other.URL+", 00008110-000000000000001e = "+mine.URL+"/") + + device := deviceWithoutTunnel("http://127.0.0.1:1") + + require.NoError(t, device.StartAgent(StartAgentConfig{})) + assert.Equal(t, mine.URL, device.deviceKitClient.BaseURL()) +} + +func TestStartAgentAcceptsAnIPv6AgentURLInAMapping(t *testing.T) { + t.Setenv(iosAgentURLEnv, "00008101-00161CEC3CDB001E=http://[fd34:13d9:5468::1]:12004,00008110-000000000000001E=http://[fd34:13d9:5468::2]:12004") + + agentURL, err := iosAgentURLOverride("00008101-00161CEC3CDB001E") + + require.NoError(t, err) + assert.Equal(t, "http://[fd34:13d9:5468::1]:12004", agentURL) +} + +func TestStartAgentTakesTheNormalPathForAnUnmappedDevice(t *testing.T) { + // the device's own agent already answers on the normal path, the mapped + // URL belongs to another device and nothing listens there + running := fakeAgent(t) + t.Setenv(iosAgentURLEnv, "00008101-00161CEC3CDB001E=http://127.0.0.1:1") + + device := deviceWithoutTunnel(running.URL) + + require.NoError(t, device.StartAgent(StartAgentConfig{})) + assert.Equal(t, running.URL, device.deviceKitClient.BaseURL()) +} + +func TestStartAgentRejectsABareURLUnlessExactlyOneDeviceIsConnected(t *testing.T) { + for _, count := range []int{0, 2} { + t.Run(fmt.Sprintf("%d devices", count), func(t *testing.T) { + withConnectedIOSDevices(t, count) + agent := fakeAgent(t) + t.Setenv(iosAgentURLEnv, agent.URL) + + err := deviceWithoutTunnel("http://127.0.0.1:1").StartAgent(StartAgentConfig{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("found %d", count)) + assert.Contains(t, err.Error(), "=") + assert.Contains(t, err.Error(), iosAgentURLEnv) + }) + } +} + +func TestMjpegURLFollowsTheAgentURL(t *testing.T) { + assert.Equal(t, "http://192.168.1.20:8100/mjpeg?fps=10", buildMjpegURLForAgent("http://192.168.1.20:8100", 10, 1)) + assert.Equal(t, "http://[fd34:13d9:5468::1]:12004/mjpeg?fps=10", buildMjpegURLForAgent("http://[fd34:13d9:5468::1]:12004", 10, 1)) + assert.Equal(t, "http://localhost:8100/mjpeg?scale=50", buildMjpegURL(8100, 0, 0.5)) +} + +// '=' and ',' in the path of a bare URL do not make it a mapping +func TestBareAgentURLMayHaveMappingSeparatorsInItsPath(t *testing.T) { + withConnectedIOSDevices(t, 1) + for _, value := range []string{ + "http://localhost:8100/proxy=v2", + "http://localhost:8100/a,b", + "https://[fd34:13d9:5468::1]:12004/x=1,y=2/", + } { + t.Setenv(iosAgentURLEnv, value) + + agentURL, err := iosAgentURLOverride("00008110-000000000000001E") + + require.NoError(t, err, value) + assert.Equal(t, strings.TrimSuffix(value, "/"), agentURL, value) + } +}